diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7feb1ae..73e197e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,7 +2,7 @@ name: tests on: push: - branches: [main] + branches: [main, codex/order-lifecycle-storage] pull_request: jobs: @@ -49,3 +49,32 @@ jobs: out=$(env -i HOME=/tmp/norust-home PATH=/usr/bin:/bin /tmp/norust/bin/python -m pip install --no-deps dist/*.tar.gz 2>&1 || true) echo "$out" | tail -20 echo "$out" | grep -q "sh.rustup.rs" || { echo "the install-Rust message did not appear"; exit 1; } + + + postgres: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:18 + env: + POSTGRES_DB: synpath_test + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d synpath_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: pip install -e ".[dev,postgres]" + - name: PostgreSQL journal, recovery and mocked venue contracts + run: pytest -q tests/test_postgres_storage.py tests/test_owner_migrations.py tests/test_routed_execution_scope.py tests/test_routed_scope_transactions.py tests/test_routed_audit_pagination.py + env: + SYNPATH_TEST_POSTGRES_DSN: postgresql://postgres:postgres@localhost:5432/synpath_test diff --git a/README.md b/README.md index 649c0d2..1309397 100644 --- a/README.md +++ b/README.md @@ -204,10 +204,12 @@ async with KalshiTrading(require("kalshi", creds)) as kalshi, \ **REST server** ```bash -python -m synpath.server # http://127.0.0.1:8000/docs +python -m synpath.server # http://127.0.0.1:8000/docs, no auth: keep it on localhost curl "localhost:8000/venues/kalshi/markets?limit=1" ``` +`synpath serve` runs the same routes (batch reads and search included), plus live books at `/ws/market` (a bucket's merged book too, with a router preview at `/trading/buckets/{id}/plan`) and Synpath's hosted matching and tick history at `/matching` and `/history` (with serve's own Synpath API key), behind its access token; see [docs/server.md](docs/server.md). + Trading has its own app, with per-account keys and an append-only audit log: ```bash @@ -252,3 +254,10 @@ secret. ## License MIT + + +Experimental execution storage on `codex/order-lifecycle-storage` supports SQLite +(default) and explicitly configured PostgreSQL, with one active execution worker. +See [engine README](synpath/engine/README.md) for configuration, recovery and test +coverage. Terminal integration and remaining live venue guarantees are tracked +separately; this experimental branch stays unmerged. diff --git a/docs/api.md b/docs/api.md index dfbf90e..d4830f7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -151,6 +151,12 @@ Because results are ranked by relevance, paging deep into a large result set is best effort. To enumerate every market, walk `fetch_markets()` without a query. +Over HTTP, `synpath serve` answers one venue's search at +`GET /venues/{venue}/markets?query=`, and several venues' at once at +`GET /search/markets` and `GET /search/events`, each venue's page reported +separately; `GET /markets?ids=` and `GET /books?ids=` are the batch reads +([server](server.md#batch-reads-and-search)). + ### Sort `volume`, `liquidity` or `newest`, on every venue -- but not the same way on @@ -241,6 +247,11 @@ synpath.match_event("kalshi:KXHIGHTATL-26SEP23") # events={"polymarket": ["polymarket:1003798"]}) ``` +`synpath serve` makes these calls for its clients, with its own key, at +`GET /matching/market`, `GET /matching/event` and (the hosted cross-venue +scan) `GET /matching/arbitrage`; tick history is at `GET /history/...` +([server](server.md#hosted-services-matching-and-tick-history)). + ### Status One vocabulary, one meaning per word, on every venue. diff --git a/docs/capabilities.md b/docs/capabilities.md index b521b2a..25d455e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -221,7 +221,10 @@ onboarded firms. | `fetch_settlements` | yes | yes, from the redemption activity | yes | no | no | no | no | no | no | | `fetch_queue_position` | yes | no | no | no | no | no | no | no | no | | `fetch_fee_estimate` | yes | yes | yes | yes | yes, from the fee contract, 0.25 USDT minimum | yes, the closing charge; opening fills pay nothing | yes, `rate * min(p, 1 - p)` a share | yes, the venue's buy-fee curve on the token bought | yes, 1% of a winning taker bet's profit | -| `rfq` | yes | no | no | no | no | no | no | no | no | +| `rfq` | yes; the older key, kept one release: the typed methods below | no | no | no | no | no | no | no | no | +| `rfq_taker` | yes; two-sided RFQs by contracts (`contracts_fp`) or USD (`target_cost_dollars`), accept with `worst_price` (buy YES sends `accepted_side=no`) | yes; combo RFQs, one side of the combo's YES: buy by pUSD, sell by shares; accept signs an Exchange v3 order, refused on `worst_price` before signing; Deposit Wallet, proxy or Safe only (EOAs refused) | yes; two-sided RFQs by contracts or USD (`cashOrderQty`), accept with `worst_price` | no | no | no | no | no | no; no API requests a parlay (a bettor asks on sx.bet) | +| `rfq_quoter` | yes; quote (`yes_bid`/`no_bid`), cancel, confirm (30 s, 3 s on combos); replace is cancel then send | yes; requests over the quoter WebSocket (400 ms window), quotes as signed Exchange v3 orders over REST, cancel, confirm (1 s last look, venue opt-in); replace is cancel then send | yes; quote, replace, cancel, confirm (3 s last look) | no | no | no | no | no | yes; parlay requests from `parlay_markets:global`, a quote is signed orders on the parlay market expiring with it (60 s); cancel, replace (cancel then send); no last look, so no confirm | +| `combos` | yes; multivariate collections, create (5,000 a week) and fetch by `KXMVE…` ticker, no read-only lookup by legs | yes; RFQ-only: no create or lookup at the venue; `create_combo` is offline (a provisional id until an RFQ names `combo-`), legs from the public catalogue | yes; create (idempotent) and fetch by symbol, no read-only lookup by legs | no | no | no | no | no | yes; a parlay market and its legs, fetched and looked up among the requests heard; none created through the API | | `split_merge` | no; contracts are not tokens | partial: Deposit Wallets and EOAs | no | no | no | no | no | no | no | Order types are a separate question. A venue holds `limit` and `market`; @@ -256,12 +259,15 @@ Every stream answers the `watch_*` keys the same way | `LimitlessUserStream` | no | no | no | no | yes | yes | no | no | | `SxBetMarketStream` | yes | partial | yes | yes | no | no | no | no | | `SxBetUserStream` | no | no | no | no | yes | yes | no | no | +| `SxBetParlayStream` | no | no | no | no | no | no | no | no | | `BetfairMarketStream` | yes, best offers to 10 levels | partial: top of book | no: no public trades | yes, per runner | no | no | no | no | | `SmarketsMarketStream` | partial: polled from REST quotes, every 5 s by default | partial: polled | no: read the recent tape with `fetch_trades` | partial: polled every few rounds | no | no | no | no | 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 positions, instrument state carries market status, and the balance ledger -carries account movements as venue events. +carries account movements as venue events. `SxBetParlayStream` carries +parlay requests (`parlay_markets:global`) as `parlay_request` venue events, +each with the request as an incoming `Rfq`. --- diff --git a/docs/engine.md b/docs/engine.md index 957fdf6..d6806f9 100644 --- a/docs/engine.md +++ b/docs/engine.md @@ -23,6 +23,9 @@ async with Engine({"kalshi": KalshiTrading(credentials)}, )) ``` +The experimental storage branch adds [per-type records, migrations and storage configuration](../synpath/engine/README.md). +Normal shutdown preserves orders by default; an operator halt is explicit. + ## What it promises **Nothing is sent that was not written down first.** `submit` runs risk, @@ -35,7 +38,15 @@ SQLite's `synchronous=FULL`, so this survives a power cut, not only a crash. **An order is never declared lost early.** Venues do not list an order the instant they accept it: Kalshi's demo takes a few hundred milliseconds. An intent the venue cannot account for stays in doubt until `lost_after_s` -(default a minute) has passed, and only then is swept. +(default a minute) has passed, and only then is swept. The caller of a send +with no verdict gets `SubmissionInDoubt` with the `client_order_id`; a retry +under it reconciles first and gets the order, or `SubmissionPending` while the +venue cannot say yet, never a second order. + +**A cancel never relabels a fill.** A venue's "not found" to a cancel is not +a cancellation: the engine reads the order back and records what the venue +says, `closed` with its fills if it filled first. A venue with no record of +it at all raises `CancelUnconfirmed` and changes nothing. **One engine per journal.** Starting takes a lease. A second engine on the same file refuses to trade and says who holds it. The lease is renewed while @@ -50,13 +61,17 @@ configuration is versioned in the journal, and a rejection records the version that refused it. `duplicate` refuses a repeat of the same market, side, price and size inside `duplicate_window_ms`; it skips the children the engine's own order types place, and any order whose `client_order_id` the -caller set. +caller set. `price_collar` refuses a limit order priced further than the +collar from the book midpoint; market orders (their price is a protection +bound) and the children of engine-held orders are not collared. **Halting uses the venue's own mechanism, then checks.** `halt()` calls each adapter's cancel-all, reads the book back, and cancels anything still resting one order at a time. New orders are refused from the moment it engages. Policies are `cancel`, `hold` (leave them resting, refuse new ones) and -`rearm` (refuse, then lift itself after a timer). +`rearm` (refuse, then lift itself after a timer). With `cancel` it first +cancels every open cross-venue RFQ group's members, then every open RFQ the +engine asked and every quote it made. ## The pieces @@ -67,9 +82,10 @@ Policies are `cancel`, `hold` (leave them resting, refuse new ones) and | `engine.py` | submit, cancel, edit, recovery, the sweep, halting, polling | | `risk.py` | the rules, the kill switch, the strategy pause | | `ledger.py` | positions netted on the YES leg, average cost, realized and unrealized profit, rolled up by market, book and account | -| `fair_values.py` | the mark per account and market, and when it goes stale | | `reconcile.py` | orphans, ghosts, drift, position and balance differences | -| `paper.py` | a venue that fills against a real book with queue position and the venue's fee model | +| `rfq.py` | RFQs and combos, both sides: accept and confirm through risk and the journal, the quote follower, recovery, halt | +| `rfq_group.py` | cross-venue RFQ: one RFQ per venue that lists a market, bucket or combo, one ranked list after fees, one accept per group (claimed in the journal), the rest cancelled | +| `paper.py` | a venue that fills against a real book with queue position and the venue's fee model; `PaperRfqVenue` adds RFQs with configurable makers | | `alerts.py` | the few events worth waking someone for | | `eod.py` | settlements, the daily report, rolling the risk day | | `__main__.py` | daemon mode, `status`, `halt`, `resume`, `eod` | @@ -98,7 +114,7 @@ ledger's alone. | Type | `params` | What it does | |---|---|---| | `stop_market` | none | fires once at `stop_price`; the child is a `market` order, `ioc`, priced by the engine | -| `stop_limit` | none | fires once at `stop_price`; the child is a `limit` at `price` with the order's time in force | +| `stop_limit` | none | fires once at `stop_price`; the child is a `limit` at `price` that inherits the order's time in force and expiry | | `trailing_stop` | `trail` or `trail_percent` | the stop follows the market one way and never back | | `iceberg` | `display`, `reload_delay_s`, `jitter_s`, `follow` | shows one slice; reloads after the delay, at the back of the queue | | `oco` | `legs` (two specifications) | one cancels the other, and a partial fill resizes the other | @@ -118,12 +134,12 @@ child goes out for the remaining quantity and names the parent in | Field | Stop market | Stop limit | |---|---|---| -| `market_id`, `side`, `quantity`, `client_order_id`, `reduce_only` | as on any order (`buy` is YES, `sell` is NO) | the same | +| `market_id`, `side` or `outcome` + `action`, `quantity`, `client_order_id` | as on any order (`buy` is YES, `sell` is NO; or buy/sell YES/NO) | the same | | `type` | `stop_market` | `stop_limit` | | `stop_price` | the trigger level | the trigger level | | `price` | not used | the child's limit: at or below `stop_price` for a sell, at or above it for a buy | | `trigger_source` | `touch` (default), `last` or `mid` | the same | -| `time_in_force` | how long the stop stays armed: `gtc` (default), `gtd`, `day`. `ioc` and `fok` are refused | what the limit child carries once triggered: `gtc` (default), `gtd`, `ioc`, `fok` | +| `time_in_force` | how long the whole order lives: `gtc` (default), `gtd`, `day`. `ioc` and `fok` are refused. It bounds the armed period; the child is `ioc` | the same, and the limit child inherits it: `gtc` stays `gtc`, `gtd` and `day` go as `gtd` at the order's `expires_at` | | `expires_at` | with `gtd` only | with `gtd` only | **A stop watches the price that would fill it.** `trigger_source="touch"`, @@ -132,10 +148,23 @@ stop. Not the last trade, which on a prediction market can be hours stale, and not the mid, which between a 0.30 bid and a 0.70 ask is a number nobody can trade at. `last` and `mid` are there for callers who disagree. +**A stop reads the book it is handed.** A new stop is checked against the +book the engine already holds, and again when its market is subscribed: a +venue sends no fresh snapshot for a market its connection already carries (a +screen's `/ws/market`, say), so a quiet market whose ask already meets the +stop triggers it at once rather than at the next change. A `last` stop waits +for a print made after it. + +**One time in force for the whole order**, as IBKR and FIX use it: a stop's +`time_in_force` is how long the order lives, armed and after it triggers. +A stop limit's child carries the same expiry, so a `day` stop limit whose +child still rests at the session end has the child canceled and ends +`expired`. + **A stop market's child is priced by the engine.** No venue here takes a market order without a worst price, so at the trigger the engine sends the -`market` child, `ioc`, with the touch plus 0.05 for a buy (minus 0.05 for a -sell), on the venue's tick and inside the price range. There is no parameter +`market` child, `ioc`, at the legal venue bound for the side, on the market's supplied tick +(or the conservative venue grid when no market precision is registered). There is no parameter for it. With no book to price against at that moment, the stop ends `rejected` rather than send at any price. @@ -144,8 +173,8 @@ of: `closed` (filled; for a stop market, also when the IOC child's remainder did not fill, with the unfilled quantity on the order and in its detail), `canceled` (cancelled while waiting, or for a stop limit after the trigger too, which pulls the resting child), `expired` (a `gtd` or `day` stop whose -time ran out before it triggered, or a stop limit whose child was still -resting), or `rejected` (no book for a stop market's child, or the venue +time ran out before it triggered, or a `gtd` or `day` stop limit whose child +was still resting), or `rejected` (no book for a stop market's child, or the venue refused the child). **Day is not one of these.** All three venues run nearly around the clock, so @@ -154,11 +183,11 @@ process. A `day` order is rewritten to `gtd` at the end of the configured session (`session_timezone`, `session_end`) before it reaches the adapter, so the venue holds the expiry; the journal keeps `day` alongside it. -**The order's own fields still apply.** `reduce_only` passes to every child. +**The order's own fields still apply.** The outcome and action pass to every child: a Sell YES stop's child sells YES held. `expires_at` expires the whole order, children included, and the parent ends `expired`. `post_only` is accepted only by types that rest on the book (`iceberg`, `peg`, a `twap` with -`style="limit"`); an `oco` or `bracket` takes it on its legs, and a type that +`style="limit"`, and a `stop_limit`); an `oco` or `bracket` takes it on its legs, and a type that takes liquidity refuses it rather than ignoring it. **Everything survives a restart.** Each parent's whole state is written on @@ -174,6 +203,15 @@ single market does. An order names `bucket:` the way it names `kalshi:KXX`, and the engine holds it as a `routed_limit` parent whose children are one leg per member venue. +A member can be switched off without removing it: `BucketMember(..., +enabled=False)`. It stays in the definition (and in `bucket_position`, so +anything already held there still nets in), but the router leaves it out: +no leg goes to it, its book is not merged, and the merged book in `synpath +serve` does not stream it. An order placed while a member is off carries the +bucket with only its enabled members. A definition needs at least two members +and at least one of them enabled; members default to `enabled=True`, so +definitions stored before the field existed route as before. + ```python from synpath import Bucket, BucketMember, OrderRequest, Side @@ -185,7 +223,7 @@ parent = await engine.submit(OrderRequest( market_id=bucket.market_id, side=Side.BUY, quantity=Decimal("500"), type="market", price=Decimal("0.42"), book="alpha", params={"min_stay_s": 5, "max_rounds": 20}, )) -engine.orders.get(parent.id).report() # filled, average, per venue, why it stopped +engine.managed_order_controller.get(parent.id).report() # filled, average, per venue, why it stopped await engine.bucket_position(bucket.id) # the ledger's positions, netted in bucket terms ``` @@ -243,11 +281,122 @@ assigns the id and refuses a caller without `trade` on every member venue. The client also has `fetch_buckets`, `fetch_bucket`, `archive_bucket`, `fetch_bucket_orders`, `fetch_bucket_order` (the `report()` above, typed as `BucketOrderReport`, rebuilt from the journal for an order that finished -before a restart) and `fetch_bucket_position`. +before a restart) and `fetch_bucket_position`. Over HTTP the server also +renames, edits and restores a bucket (`PATCH /trading/buckets/{id}`), keeps a +client-made UUID id when one is given, streams the merged book in bucket +terms (`bucket:` on `/ws/market`) and previews `router.plan` for an order +without placing it (`POST /trading/buckets/{id}/plan`); see +[the server](server.md#a-buckets-merged-book). Ids are UUIDs made locally, so a definition made offline stays unique when it later moves to a hosted account. Nothing here judges whether the members are really the same question. +## RFQs and combos + +`engine.rfq` (`rfq.py`) runs both sides of an RFQ on the venues that declare +`rfq_taker` / `rfq_quoter`; [`rfq-combo.md`](rfq-combo.md) has the design and +the venue facts. + +```python +rfq = await engine.create_rfq("polymarket_us:caoc-...", quantity=Decimal("10"), book="parlays") +# quotes arrive as quote.received events +quote = await engine.accept_quote(AcceptQuote(quote_id=q.id, rfq_id=rfq.id, outcome="yes", action="buy", + worst_price=Decimal("0.30"))) # returns at once: accepted +# then quote.confirmed, quote.executed, order.accepted, order.closed, fill.booked +``` + +- **An accept is an order.** The quote is re-read; a price worse than + `worst_price` is refused (rule `worst_price`); an `amount` becomes contracts + rounded down to the venue's step; the `rfq_taker` request runs every risk + rule. Then the intent `rfq-accept:{venue}:{quote_id}` is written and the + venue called. `wait=True` follows the quote to its end. +- **Results are events.** The intent stays `sending` through the maker's last + look. The `rfq` loop (or a venue's quote stream, or the sweep) reads the + quote; when it executes, the order is recorded through the normal order + path and its fills booked, and the intent becomes `sent`; when it ends + without a trade, `failed` with `quote.failed`. +- **Recovery** reads the quote back: an accept that never reached the venue + is `rejected` after `lost_after_s` and may be retried; one that did is + followed and adopted; nothing is sent twice. +- **The side the venue reports is booked.** A mismatch with the intent + publishes `rfq.side_mismatch` and a critical alert. +- **Quoter.** `send_quote`, `replace_quote`, `cancel_quote` are journaled and + checked against the halt, a paused book, the restricted list and the size + limits; `confirm_quote` runs every rule on our side of the trade and is + followed like an accept (`rfq_maker` order). The engine holds a quote's + `expires_at` and cancels it when it passes. `rfq.incoming` announces others' + RFQs. +- **Quoting in time.** Register a callback with `engine.rfq.on_incoming_rfq`; + it runs the moment an incoming RFQ is recorded and `rfq.incoming` is out. + Polymarket global's quoter session and SX Bet's parlay channel push their + requests (`subscribe_incoming_rfqs`), so there the callback runs as the + stream delivers, with no poll in between (well under a millisecond in + process; the Polymarket window is 400 ms). Kalshi and Polymarket US have + no RFQ stream here and are polled every `rfq_incoming_interval_s` (2 s). + The streams start with the `rfq` loop (`rfq_stream_incoming`, on by + default); `send_quote` finds the RFQ in `engine.rfq.incoming` and makes + no venue read before its own call. + + ```python + async def quoter(rfq): # runs inside the venue's window + if rfq.combo is None or len(rfq.combo.legs) > 4: + return + await engine.send_quote(MakerQuote(rfq_id=rfq.id, bid=Decimal("0.18"), ask=Decimal("0.22"), + book="mm"), venue=rfq.venue) + + stop = engine.rfq.on_incoming_rfq(quoter) # stop() unsubscribes + ``` +- **Restarts on a venue that lists nothing.** Polymarket global's gateway + has no list of a requester's RFQs, so at start (`recover()`) the engine + hands each adapter its open RFQs from the working set and the quotes whose + accept or confirm is in doubt (`TradingExchange.restore_rfqs`, a no-op + elsewhere). An accept in flight is then followed to executed or failed, + and an open RFQ can still be cancelled. +- **A combo is an instrument.** Its own market id carries the order, fills, + position, P&L and reconciliation; its legs are kept as `combo.saved` events + and shown in `Position.info["combo"]`. + +### Cross-venue RFQ + +`engine.rfq_groups` (`rfq_group.py`) asks every venue that lists the same +thing at once and ranks every answer as one list +([design](rfq-combo.md#10-cross-venue-rfq)). + +```python +group = await engine.create_rfq_group("bucket:9f2c...", quantity=Decimal("10"), book="arb") +group = await engine.create_rfq_group("kalshi:KXFOO-25", amount=Decimal("100"), cross_venue=True) +group = await engine.create_rfq_group(legs=[ComboLeg(market_id="kalshi:KXA-26-X", outcome="yes"), + ComboLeg(market_id="kalshi:KXB-26-Y", outcome="no")], + quantity=Decimal("10")) +group.members, group.skipped # one RFQ per venue; the venues left out, with reasons +best = group.best() # ranked by effective price after each venue's taker fee +done = await engine.accept_group_quote(group.id, best.quote_id, venue=best.venue, worst_price=Decimal("0.45"), wait=True) +``` + +- **Members.** A bucket's enabled members (with `flip`); a combo's legs + mapped onto each combo venue (by the leg's own venue, a bucket member, or + hosted matching with its `side_map`), checked for eligibility (Kalshi + collections, the Polymarket lists) and built there; a market and, with + `cross_venue=True`, its matches. Each member is a desk RFQ. Matching is + `Engine(..., matcher=...)`, by default `synpath.matching.match_market`. +- **Ranking.** Every quote in the group's YES terms (a flipped member's bid + `b` is the group's ask `1 - b`), plus the venue's taker fee for the size + (`fetch_fee_estimate`), as the router prices a bucket. An amount ranks by + the contracts it buys after the fee. `rfq_group.quote` then + `rfq_group.updated` as quotes arrive. +- **One accept.** `accept_group_quote` checks the accept first (nothing + written on a refusal), claims the group with the intent + `rfq-group-accept:{group}:{n}` (refused while an earlier attempt still + holds it), cancels every other member (Polymarket global's lapse instead), + then accepts through `accept_quote`. An accept that does not trade frees + the group for another quote; cancelled venues stay cancelled. A member's + quote cannot be accepted around the group. +- **Recovery and halt.** Groups not final are in the cursor `rfq:groups` + and come back at `recover()`, with their member RFQs re-seeded + (`restore_rfqs`). A claim whose accept was never sent is released after a + restart, nothing traded. The halt (`cancel` policy) cancels every open + group's members; any halt refuses a new accept. + ## The ledger Positions net on the YES leg, which is the leg every fill arrives on: a @@ -256,9 +405,12 @@ neither. What was actually bought is kept per side as well, because on Polymarket those are real tokens. Realized profit is taken on the way out against average cost, with fees -realized when charged. Unrealized profit needs a mark, and a mark is a -choice: it comes from `fair_values`, and a stale one is reported as no mark -rather than as a number. +realized when charged. Unrealized profit needs a mark: the +price the position could be closed at on the engine's live book, the best bid +for a long YES and the best ask for a short (a long NO). The engine subscribes +the book of every market it holds. With no ready book, or nobody on that side, +the mark is the last trade; with no trade either, the position has no mark and +no unrealized number. Three levels roll up: @@ -309,7 +461,9 @@ synpath serve --config engine.toml # the venues, risk rules and journal the fi (`Engine.background()`: lease, in-doubt sweep, poll, the managed-order clock), subscribes each venue's market and account streams (`synpath.engine.feeds`), runs reconciliation and the end-of-day close, and serves the HTTP API: market -data at `/`, trading at `/trading`. The first start on a new control database +data at `/`, live books, trades and quotes at `/ws/market` (on the same venue +connections the stops read), trading at `/trading`. Every route but `/`, +`/health` and the API docs needs the access token. The first start on a new control database makes an owner access token and leaves it in `~/.synpath/servers.json` (readable by you only), where `synpath.Client(server=...)` and the TypeScript client on the same machine find it; a server bound to a network address prints the token @@ -352,6 +506,12 @@ price_collar = "0.10" daily_loss_limit = 500 max_open_orders = 50 self_trade_prevention = "firm" + +# synpath serve only: the combo boards it keeps warm (docs/server.md, "The combo board"). +[combo_board] +warm = true # false: build a board on request (5 pages, kept 30 s) +refresh_interval_s = 60 # rebuild each board this long after its last build ended +pause_s = 0.1 # between two reads of one venue during a build ``` Credentials never appear in the configuration: they come from the @@ -379,6 +539,18 @@ its process. fire early, a TWAP whose slices are real orders, a parent that survives a restart without sending a second child, a halt, and a Day order the venue holds the expiry for. +- **RFQ and combos, 26 offline tests against `PaperRfqVenue`** (each on + SQLite and PostgreSQL): accept to order, fill and position with the events + in order; accept by amount; the worst-price, risk and kill-switch refusals + sending nothing; a partial fill; a maker letting the last look lapse; a + stream and a fill poll moving the accept; a crash between the intent and + the call, and between the call and its result, on both sides; the side + guard; a combo position reconciled on its own instrument; the quoter flow, + confirm through risk, expiry, replace; a halt cancelling every RFQ and + quote; and a pushed incoming RFQ reaching the quoting callback, which + quotes it, with no poll (`TestIncomingPush`). The Polymarket global tests + add a restart that follows an accept to a position, one whose accept never + landed, a cancel after a restart, and a confirm in doubt. - **On Kalshi's demo, four tests, twice in a row:** a submitted order is found at the venue with the client order id the journal wrote; the restart path adopts a real order rather than sending a second one; a halt cancels @@ -388,7 +560,7 @@ its process. Run them with: ```bash -pytest tests/test_engine.py tests/test_engine_orders.py +pytest tests/test_engine.py tests/test_engine_orders.py tests/test_engine_rfq.py pytest -m demo tests/test_engine_demo.py tests/test_engine_orders_demo.py ``` diff --git a/docs/rfq-combo.md b/docs/rfq-combo.md new file mode 100644 index 0000000..fa559e2 --- /dev/null +++ b/docs/rfq-combo.md @@ -0,0 +1,1000 @@ +# RFQ and combos: research and plan + +Status: **P1 (models), P2 (Polymarket US adapter), P3 (engine, taker), P4 (engine, quoter) and P5 (serve routes) built; P6 (Kalshi adapter) built; P7 (Polymarket global combos, RFQ and Exchange v3 signing) built; P8 (SX Bet parlay quoter) built; the two engine gaps (pushed incoming RFQs, Polymarket global restarts) closed** (2026-10-21, "Gaps closed" below); **cross-venue RFQ built** (2026-10-22, §10). Decisions from Yuki are in §2 and §9. Experimental line only (base +`origin/codex/order-lifecycle-storage`, never merged into `main`). Nothing here +has been run against a live or demo account. Anything marked UNVERIFIED is +not confirmed by a venue document or a public read. + +## 1. What each venue offers + +| Venue | RFQ | Combos / multi-leg | How a combo trades | Public activity signal | +|---|---|---|---|---| +| Kalshi | both (requester and quoter), REST, WS, FIX | venue-created combo **markets** in multivariate event collections | ordinary binary market; RFQ in practice | sampled combo books empty; volume present | +| Polymarket (global) | both, separate RFQ gateways | combo **positions** built from 2–50 legs, on-chain | RFQ only, signed Exchange v3 order | 50+ combo-enabled markets in the public catalogue | +| Polymarket US | both, on the retail API | user-created combo **instruments**, 2–50 legs (FAQ says 2–10) | ordinary order book; RFQ optional | anonymous RFQ prints (needs auth) | +| SX Bet | quoter only over the API | parlay **market** created per request, lives 60 s | makers post orders; bettor takes FOK/IOC | public WS channel `parlay_markets:global` | +| Limitless | none on the API (UNVERIFIED: "RFQ purchases" exist outside it) | curated **Packs**, a house-backed parlay with 2–10 legs | quote, then buy against a treasury vault | 37 active packs with 2–8 legs (public list) | +| Smarkets | none | venue-created accumulator markets (`domain=accumulator`) | ordinary back/lay contracts | 0 accumulator events listed today | +| Betfair | none | none on the Exchange API (UNVERIFIED; accas are a Sportsbook product) | — | — | +| Opinion | none | none | — | — | +| Hyperliquid | none | none (HIP-4 outcome markets only) | — | — | +| predict.fun | none | none | — | — | + +### Kalshi +- **RFQ endpoints** (signed with the trading key; any API member; whether quoting needs maker status is UNVERIFIED, though collections list `active_quoters` per event): + `POST/GET /communications/rfqs`, `GET/DELETE /communications/rfqs/{id}`, + `GET/POST /communications/quotes` (filters `rfq_id`, `rfq_user_filter=self`, `user_filter=self`), + `GET/DELETE /communications/rfqs/{rfq}/quotes/{q}`, + `PUT .../accept` with `{accepted_side: yes|no}`, `PUT .../confirm` (quoter), `GET /communications/id`. + The `/communications/quotes/{id}/...` forms are deprecated. Source: https://docs.kalshi.com/openapi.yaml (v3.34.0). +- **Semantics** (https://docs.kalshi.com/getting_started/rfqs.md): an RFQ is sized by `contracts_fp` or `target_cost_dollars`. A quote carries `yes_bid` and `no_bid` for the full size, with either one "0" and their sum at most $1. The requester accepts one side, and the maker must confirm within 30 s (3 s on combo/HVM markets). Orders reach the book after 15 s (1 s on HVM markets). Fills appear in `/portfolio/fills`, matched on `rfq_creator_order_id`. At most 100 open RFQs, one per market (409 otherwise). REST accept takes no client id; FIX accept takes `ClOrdID` and a partial `OrderQty`. +- **Side mapping, UNVERIFIED:** the docs define `yes_bid` as the maker's YES bid, so buying YES costs `1 − no_bid` and selling YES gets `yes_bid`. The REST docs call `accepted_side` "the side of the quote to accept". FIX says "BUY accepts the maker's NO quote". Either way the economics are fixed, but whether Buy YES sends `accepted_side=no` or `yes` must be confirmed on the demo before anything ships. The same doubt applies to `yes_contracts_fp`/`no_contracts_fp`. +- **Combos:** `GET /multivariate_event_collections` (public; 3 open today: `KXMVECROSSCATEGORY-R`, `KXMVECROSSCATEGORY-SHARD1-R`, `KXMVESPORTSMULTIGAMEEXTENDED-R`) and `GET /multivariate_event_collections/{t}`. `POST /multivariate_event_collections/{t}` creates or returns the market for `selected_markets:[{market_ticker,event_ticker,side}]`. It is signed, capped at 5,000 a week, and "must be hit at least once before trading or looking up a market". There is no lookup endpoint left in the spec. `GET /events/multivariate?collection_ticker=` lists existing combos, and each market carries `mve_collection_ticker` and `mve_selected_legs`. A combo is a normal binary market (`KXMVE…` ticker) with its own book, positions and settlement. REST RFQs need `market_ticker`, so a combo is created before its RFQ. (FIX can send legs directly.) +- **Activity** (public read, 2026-10-21): in one page of 170 cross-category combos, 7 had volume (1,509 contracts in total). All 23 multi-game combos had volume (16,000 in total). Every sampled book was empty, so liquidity comes from RFQ. + +### Polymarket (global) +- **Requester:** `https://combos-rfq-gateway-requester-api.polymarket.com/v1/requester/rfq`. `POST /requests` returns the best quote at once. `POST /requests/{id}/accept` takes a signed **Exchange v3** EIP-712 order. Poll `GET /requests/{id}` (FILLED/CONFIRMED, or FAILED/EXPIRED/CANCELED). Auth is CLOB L2 HMAC. EOA wallets are refused; Deposit, Proxy or Safe wallets only. Only `side: "YES"`. A BUY is sized in notional, a SELL in shares. You get 5–10 s to accept (the docs disagree), and there are 15 creates a minute. The docs warn there is **no idempotency key** on create. Source: https://docs.polymarket.com/trading/combos/requesters.md, /trading/combos/overview.md. +- **Quoter:** a WS session plus `/v1/maker/quotes`, `/v1/maker/quotes/cancel` and `/v1/maker/confirmations`. Quotes are due within 400 ms; last look is 1 s. On-chain approvals are needed. No approval programme is stated, only a Telegram group. Source: https://docs.polymarket.com/trading/combos/market-makers.md, https://docs.polymarket.com/api-spec/combos-rfq-openapi.yaml. +- **Catalogue:** `GET https://combos-rfq-api.polymarket.com/v1/rfq/combo-markets` (public). +- **Exchange v3 signing:** built in P7 (`synpath/trading/polymarket_v3_signing.py`), beside the CLOB V2 signing, which it does not change. + +### Polymarket US (retail API, the same Ed25519 key as orders) +- **Combos:** `POST /v1/combos {legs:[{symbol, side: SIDE_BUY|SIDE_SELL}]}` is canonical: the same legs always give the same `caoc-…`/`crossd-…` symbol, with a quota of 1,000 a week. `GET /v1/combos?symbol=`. +- **Eligibility:** no combo-markets list. Each gateway market carries `comboEnabled` (public `GET /v1/events`, markets nested; no filter on it, so it is read per event), and the docs say there is no way to validate legs before `POST /v1/combos`. Read 2026-10-22: 54 of 862 markets in the top 100 open events were combo-enabled, all sports. +- **RFQ:** `GET/POST /v1/rfqs` (`qtyDecimal` or `cashOrderQty`, `restRemainder`), `DELETE /v1/rfqs/{id}`, `GET/POST /v1/rfqs/quotes`, `DELETE …/quotes/{q}`, `PUT …/accept {acceptedSide}`, `PUT …/confirm`, `GET /v1/rfqs/trades`, `GET /v1/rfqs/user-id`. Combo and RFQ creation share a limit of 10 per 10 s. +- **Unambiguous sides:** `buyPrice` is the price for a requester BUY, and `acceptedSide=SIDE_BUY` takes it. +- **Execution:** the exchange submits two ordinary orders, the maker's first. The requester's `clord_id` is `rfq1.…` and deterministic, so a retried accept cannot make a second order. `rfqCreatorOrderId` is ours. EXECUTED means submitted, not filled. With `restRemainder: false` the order is IOC, so it can fill partly. Last look is 3 s, then 1 s before submission. A combo settles on its own symbol at the product of its leg values, and shows up in positions like any instrument. +- Sources: https://docs.polymarket.us/api-reference/combos/overview.md, /api-reference/rfqs/overview.md, /partners/orders/combos-takers.md, /trader-guide/combos.md, /faqs/combos-faqs.md, and the schemas under /api-reference/oapi-schemas/{combos,rfqs}-schema.json. + +### SX Bet +- A bettor's parlay request makes a real market (`marketHash`, `legs[{marketHash, bettingOutcomeOne}]`) that expires after 60 s. Makers hear about it on the Centrifuge channel `parlay_markets:global`, using a token from `GET /user/realtime-token-v3/api-key` (needs an API key). They post ordinary signed orders, which must expire by then. The taker uses FOK/IOC. Fees are `makerParlayPayoutFee`/`takerParlayPayoutFee` (`GET /user/fees-v3`); the user guide says 5% on profit. +- There is **no documented API to create a parlay request**, so the requester side is UNVERIFIED and appears to be UI-only. +- Settlement wording conflicts: the developer docs say one VOID leg voids the parlay (every stake back), while the user guide says a VOID or push leg loses a parlay WIN bet and a parlay LOSE bet needs no VOID leg to win. Synpath takes the worse reading for the side held (P8). +- The request carries no expiry and no bettor side for the parent; `requestSize` is a stake in base units that "does not limit how much you can offer". An order signed past the parlay's end is refused with `PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY` and the exact end (`parlayExpiry`, unix seconds); after the end, new orders get `INVALID_MARKET`; an inactive leg gives `PARLAY_LEG_INACTIVE` (OpenAPI `ErrorOrderRule`). A parlay fill's `marketHash` is the parent. Sources add /api-reference/channel-parlay-requests.md, /developers/time-in-force.md, /openapi.json. +- Sources: https://docs.sx.bet/developers/parlays.md, /developers/market-making-parlays.md, /api-reference/parlay-markets.md, /user-guides/trading/parlays.md. + +### Limitless +- Packs are curated by the venue and backed by a treasury vault. They are not an order book. +- Public endpoints: `GET /parlay/packs`, search and detail. +- Partner endpoints: `POST /parlay/partner/quote` and `/buy` (HMAC with the `trading` and `delegated_signing` scopes; buy takes an idempotency key), plus positions and claim. +- Stakes run $1–70, up to 20x, with a 10% vig. +- The docs mention "custom baskets and RFQ purchases" outside this API (UNVERIFIED). +- Source: https://docs.limitless.exchange/user-guide/packs.md, /developers/partner-packs.md. + +### Smarkets, Betfair, Opinion, Hyperliquid, predict.fun +- **Smarkets:** the trading OpenAPI (https://api.smarkets.com/v0/control/openapi/?tags=trading_api) describes accumulator markets, built by the venue, whose `contract_selections` name the component contracts. It has no endpoint to create one, and `GET /v3/events/?type_domain=accumulator` returned none today. +- **Betfair:** placeOrders takes single selections only; no multiples are documented (https://betfair-developer-docs.atlassian.net, UNVERIFIED). +- **Opinion, Hyperliquid, predict.fun:** no RFQ or combo terms in their doc indexes (https://docs.opinion.trade/llms.txt, https://hyperliquid.gitbook.io/hyperliquid-docs/llms.txt, https://dev.predict.fun/llms.txt). + +## 2. Scope (Yuki, 2026-10-21) + +Every venue whose API allows it, on both sides, plus combos. The quoter side +is in scope from the start. + +| Venue | Taker (`rfq_taker`) | Quoter (`rfq_quoter`) | Combos (`combos`) | Notes | +|---|---|---|---|---| +| Polymarket US | yes | yes | yes | user-created combo instruments, on the API we already sign | +| Kalshi | yes | yes | yes | combos through multivariate event collections | +| Polymarket global | yes | yes | yes | needs **Exchange v3 order signing** (new) and a non-EOA wallet | +| SX Bet | no | yes (parlay) | parlay markets, quoter side | no API to request a parlay | +| Limitless | no | no | no | not in scope, see below | +| Smarkets, Betfair, Opinion, Hyperliquid, predict.fun | no | no | no | not in scope, see below | + +**Not in scope, with the reason:** +- **Limitless:** Packs are a house-backed parlay (a treasury vault, not RFQ), and the + quote and buy endpoints need partner credentials (HMAC with the `trading` and + `delegated_signing` scopes and an owned managed server-wallet sub-account). + Deferred (Yuki, 2026-10-21); revisit if partner access is wanted. +- **Smarkets:** accumulator markets exist but are venue-built, with no create + endpoint and none listed today. They trade as ordinary contracts already. +- **Betfair:** placeOrders takes single selections; multiples are a Sportsbook product. +- **Opinion, Hyperliquid, predict.fun:** no RFQ or combo API. + +The capability keys go True phase by phase, when the adapter that answers them +lands (§8); until then every venue declares them False. A venue outside the +scope column above never claims them. + +## 3. Model (in `synpath/trading/types.py`) + +Every price is a YES price and every object keeps the venue's raw payload in +`info`, as for orders. Every outcome and action follows `OrderRequest`. + +- `ComboLeg {market_id, outcome: yes|no, event_id?, info}`. On Polymarket US, `outcome=no` means `SIDE_SELL`. +- `Combo {market_id (the combo's own id, kalshi:KXMVE… / polymarket_us:caoc-…), venue, collection_id?, legs[2+], status?, created_at?, info}`. +- `Rfq {id, venue, account?, market_id | combo_id (exactly one), combo?, outcome?, action?, quantity | amount (exactly one), status, closed_reason?, mine, created_at?, expires_at?, updated_at?, info}`. `combo_id` is the combo's own market id; `combo` carries its legs where known. `outcome` and `action` are `None` on a two-sided request (Kalshi, Polymarket US); Polymarket global asks one side. +- `RfqQuote {id, rfq_id, venue, market_id?, bid, ask, bid_quantity, ask_quantity, status, accepted_side?, confirm_by?, execute_by?, expires_at?, taker_order_id?, maker_order_id?, mine, created_at?, updated_at?, info}`. `ask` is the YES price we can buy at (Kalshi `1−no_bid`, Polymarket US `buyPrice`); `bid` the YES price we can sell at (Kalshi `yes_bid`, Polymarket US `sellPrice`). A side the maker declined is `None`. Named `RfqQuote` because `synpath.Quote` is already the market-data quote. +- `AcceptQuote {quote_id, rfq_id?, outcome, action, worst_price?, quantity?, amount?, client_order_id?, book, trader, tags}`. `worst_price` is a YES price, like every price. +- `MakerQuote {rfq_id, bid?, ask?, quantity?, expires_at?, client_order_id?, rest_remainder, post_only, book, trader, tags}`. At least one of `bid`/`ask`; `bid < ask`. `post_only` (added in P2) is Polymarket US `postOnly`. +- Status enums `RfqStatus`, `RfqCloseReason`, `QuoteStatus` (§4). +- Capability keys `rfq_taker`, `rfq_quoter` and `combos`, declared by every trading adapter. The old `rfq` key stays for one release: it says the adapter has the raw Kalshi RFQ methods. + +### Sizing by amount + +An RFQ is sized by contracts (`quantity`) **or** money (`amount`), never both. + +- Where the venue takes money natively (Kalshi `target_cost_dollars`, + Polymarket US `cashOrderQty`, a Polymarket global BUY) the amount is sent as is. +- Otherwise it is converted at accept time: `contracts = amount ÷ price`, where + `price` is the quote's price for the token traded, rounded **down** to the + venue's quantity step (`VENUE_RULES` / `Precision`), and refused below the + venue's minimum quantity or notional. `worst_price` caps the spend: a quote + worse than it is refused, so the spend is at most `contracts × worst_price ≤ amount`. +- The inverse (`contracts × price`) is for display. +- Helpers: `synpath.trading.money.contracts_for_amount`, `amount_for_contracts`, + `outcome_price`; `synpath.trading.instruments.venue_precision` for a venue with no market loaded. + +## 4. Lifecycle + +- **RFQ** (`RfqStatus`): `open` → `closed`, with `closed_reason` one of `canceled` + (we deleted it), `expired`, `executed` (a quote traded) or `replaced`. +- **Quote** (`QuoteStatus`): `open` → `accepted` (by the requester) → `confirmed` + (the maker's last look) → `executed` (orders submitted) → order and fills. + Before `executed` a quote can end `canceled` (the maker withdrew or the RFQ + closed), `expired` (no accept, or no confirm, by the deadline) or `failed` + (the venue reported the execution failed). Terminal: `executed`, `canceled`, + `expired`, `failed`. +- An executed quote becomes an order: an `rfq_taker`-type `Order` on the quote's + market, stamped with the caller's outcome and action. Its fills are ordinary fills. + +## 5. Engine integration (a new `synpath/engine/rfq.py`, attached as `engine.rfq`) + +1. **Accept is an order, and returns at once.** + - Re-read the quote (it must be `open`), take the price for the requested outcome and action, and refuse if it is worse than `worst_price`. Size an `amount` here (§3). + - Build an `OrderRequest(type=rfq_taker, outcome, action, quantity, price, params={rfq_id, quote_id})` and run `engine.evaluate_order_risk`: the kill switch, a paused book, the restricted list, size and notional, position caps, closing soon, daily loss. The price collar is skipped, as for market orders, because combos rarely have a mark. + - Journal the intent (`operation="accept_quote"`, `target_order_id=quote_id`, key `rfq-accept:{venue}:{quote_id}`), mark it `sending`, call the venue, and return the intent. A network error raises `SubmissionInDoubt` and leaves the intent `sending`. + - An optional `wait` (with `timeout_s`) blocks until the quote is terminal; the default does not wait. +2. **Results arrive as events.** A follower polls the quote (or reads the venue's stream): + - `executed`: fetch the taker order id and pass it to `on_order`; read fills (`fetch_my_trades(order_id=)` on Kalshi, the private stream on Polymarket US) and pass each to `on_fill`. Publish `quote.executed`; the intent becomes `sent`. + - `canceled`, `expired` or `failed` after our accept: publish `quote.failed` with the reason; the intent becomes `failed` ("nothing traded"). + - Book the side the **venue** reports. If it differs from the intent's side, publish `rfq.side_mismatch` and alert (the guard for the unverified Kalshi mapping, until P0 settles it). +3. **Crash recovery.** `Engine._resolve` hands `accept_quote` and `confirm_quote` intents to `engine.rfq.resolve`, which reads the quote: `executed` adopts the order; still `open` after `lost_after_s` marks the intent `rejected` (built that way in P3: nothing traded, and the accept may be retried); `accepted`/`confirmed` stay pending; venue unreachable stays unresolved. +4. **Combo as an instrument.** Positions are keyed by market id, so a combo's position, P&L and settlement live on its own id. Leg definitions go in the journal (`combo.saved`). No schema change: RFQ records are `rfq.*`/`quote.*` events, and the venue is the source of truth for listing them. +5. **Quoter side.** Sending and cancelling a quote commits nothing until a requester accepts, so neither runs the position rules, but both are journalled; sending (and creating an RFQ) is checked against the halt, a paused book, the restricted list and the order size limits (P4). **Confirm** commits capital: it goes through risk and the journal like accept, with `operation="confirm_quote"` and the opposite side. +6. **Halt.** The kill switch (`engine.halt`, `cancel` policy) cancels every open RFQ we created and every open quote we made, on every venue in scope, before cancelling orders; accept and confirm are refused while halted. An accepted quote awaiting our confirm is left to expire rather than confirmed. + +## 6. serve routes, events, grants + +Routes under `/trading` (final paths, P5; quote ids are flat, with `rfq_id` +in the body or query where the venue addresses a quote under its RFQ): + +| Route | Grant | +|---|---| +| `POST /rfqs`, `DELETE /rfqs/{id}` | `trade` | +| `GET /rfqs` (`venue`, `status`, `market_id`, `source`, `limit`), `GET /rfqs/{id}`, `GET /rfqs/{id}/quotes` | `view` | +| `POST /quotes/{id}/accept` (`AcceptQuote` plus `wait`, `timeout_s`) | `trade`; answers 202 at once (or when in doubt), 200 when it waited and the quote is final | +| `GET /rfqs/incoming`, `GET /quotes` (quoter) | `view` | +| `POST /quotes`, `PUT /quotes/{id}`, `DELETE /quotes/{id}`, `POST /quotes/{id}/confirm` (quoter) | `trade` | +| `GET /combos/{market_id}`, `POST /combos/lookup`, `GET /combos/collections` (Kalshi, P6) | `view` | +| `GET /combos/markets` (Polymarket, Polymarket US; `event_id`, `tag`, `limit`, `cursor`) | `view`; cached 15 s with collections | +| `POST /combos` (`collection_id` on Kalshi) | `trade` (a signed write against a weekly quota) | + +- Grants apply on the account of the venue the route names. +- `/ws/events` carries `rfq.created`, `rfq.closed`, `quote.received`, `quote.accepted`, `quote.confirmed`, `quote.executed`, `quote.failed`, `quote.canceled`, `rfq.side_mismatch`, and the ordinary `order.*`/`fill.*` events of the resulting order. +- The OpenAPI document picks the routes up by itself; the TypeScript client gains typed methods and the generated types (`npm run types`). + +## 7. What the terminal needs (separate terminal PRs, Richard OK'd) + +- **T1, RFQ ticket and blotter** (after P5): the ticket's RFQ type ("Max price", "Quote window", contracts or amount) maps to `POST /rfqs`, the `quote.*` events, then accept with `worst_price` = Max price. The quote window is a client timer plus `DELETE /rfqs/{id}`. The blotter (product plan G10) reads `GET /rfqs` and `GET /rfqs/{id}/quotes`. +- **T2, combo builder and combo positions** (after P5 and P2/P6): reads collections (P6; each with `associated_event_tickers`) or `GET /combos/markets` (Polymarket, Polymarket US) to decide which legs are eligible, then `POST /combos` (naming `collection_id` on Kalshi, where today's three collections take the same events); Positions and Fills show a combo as one row with its legs from `GET /combos/{id}`. +- **T3, quoter panel** (after P4): incoming RFQs, quote entry, confirm, and a cancel-all wired to halt. + +## 8. Phases and exit criteria + +Dependencies: P0 ∥ P1 → P2 → P3 → P4 → P5 → (P6 ∥ P7 ∥ P8) → P10. (P9, Limitless Packs, was removed on 2026-10-21; the number is not reused.) T1 after P5; T2 after P5 and P2/P6; T3 after P4. + +| Phase | Work | Exit | +|---|---|---| +| P0 | Kalshi demo check: the accept side (create, read, delete an RFQ; accept if a demo quote appears) and the quoter flow (quote a demo RFQ, confirm). Needs the demo key; **Yuki is told before it runs.** | `accepted_side`, `yes/no_contracts_fp` and confirm mapping settled; samples recorded under `tests/samples/` | +| P1 | models, status enums, capability keys, sizing helpers, exports | `tests/test_rfq_models.py` and contract tests pass; full suite green | +| P2 | Polymarket US adapter: combos, RFQ, accept, quoter (quote, cancel, confirm), typed normalisers | mocked-HTTP tests of every call; its three keys True. **Done 2026-10-21** (below) | +| P3 | engine, taker side: risk, intent, follow, events, recovery, ledger, amount sizing | accept → order and fill → position; a crash at each step recovers; a risk refusal sends nothing; SQLite and PostgreSQL. **Done 2026-10-21** (below) | +| P4 | engine, quoter side: quote, cancel, confirm through risk and journal; halt cancels every open RFQ and quote | confirm → order and fill; halt leaves no open RFQ or quote (mocked); recovery of `confirm_quote`. **Done 2026-10-21** (below) | +| P5 | serve routes, events, grants, TypeScript client | route tests including 403 for view-only, 202 by default and 200 with `wait`; OpenAPI lists the routes; TS client typechecks. **Done 2026-10-21** (below) | +| P6 | Kalshi typed adapter: RFQ both sides, multivariate collections and combo create | mocked tests; demo test updated (`demo` marker); its three keys True. **Done 2026-10-21** (below) | +| P7 | Polymarket global: Exchange v3 EIP-712 signing, requester and maker gateways, combo catalogue | signing vectors match the venue's published examples; mocked tests; its three keys True. **Done 2026-10-21** (below; the venue publishes no v3 vectors, so they are an independent recomputation of its documented payloads) | +| P8 | SX Bet parlay quoter: `parlay_markets:global` stream, orders that expire with the parlay | mocked stream and order tests; `rfq_quoter` and `combos` True. **Done 2026-10-21** (below) | +| P10 | tiny live tests, one per venue: one small RFQ (or quote) executed and reconciled. Pre-approved by Yuki; **Yuki is told before each run.** | one execution per venue reconciled against venue positions and fills | + +### Typed interface (P2, 2026-10-21) + +Defined on `TradingExchange` with `NotSupported` defaults, so the engine (P3) +and the Kalshi (P6) and Polymarket global (P7) adapters share one set of names: + +| Side | Method | +|---|---| +| combos | `create_combo(legs) -> Combo`, `fetch_combo(combo_id) -> Combo`, `lookup_combo(legs) -> Combo \| None` | +| taker | `create_rfq(instrument_id, *, quantity \| amount, outcome?, action?, rest_remainder=False, combo?) -> Rfq`, `fetch_rfq(id) -> Rfq`, `fetch_rfqs(*, status?, market_id?, limit?, cursor?) -> Page[Rfq]` (own), `cancel_rfq(id) -> None`, `fetch_quotes(rfq_id, *, status?) -> list[RfqQuote]`, `fetch_quote(quote_id, *, rfq_id?) -> RfqQuote`, `accept_quote(AcceptQuote) -> RfqQuote` | +| quoter | `fetch_incoming_rfqs(*, market_id?, limit?, cursor?) -> Page[Rfq]`, `send_quote(MakerQuote) -> RfqQuote`, `replace_quote(quote_id, MakerQuote) -> RfqQuote`, `cancel_quote(quote_id, *, rfq_id?) -> None`, `confirm_quote(RfqQuote) -> RfqQuote`, `fetch_my_quotes(*, rfq_id?, status?) -> list[RfqQuote]` | + +A missing RFQ, quote or combo is `RfqNotFound` (a subclass of `OrderNotFound`). +`KalshiTrading`'s raw RFQ methods were replaced by these in P6 (`delete_rfq` is now `cancel_rfq`); +the `rfq` key stays True for one release. + +### P2 result (Polymarket US, offline, 2026-10-21) + +Built in `synpath/trading/polymarket_us.py`, tested offline in +`tests/test_polymarket_us_rfq.py` (samples `tests/samples/polymarket_us_rfq_*.json`). +`rfq_taker`, `rfq_quoter` and `combos` are True on `PolymarketUSTrading` only. Decisions: + +- **Ids.** A combo symbol is a market slug like any other (the docs use the slug as `symbol` everywhere), so a combo's id is `polymarket_us:caoc-…`/`polymarket_us:crossd-…` and its orders and positions use it. +- **Combos.** `create_combo` is idempotent (same legs, same symbol; an existing combo does not use the weekly quota). The venue has **no read-only lookup by legs**, so `lookup_combo` is `NotSupported` and says to use `create_combo`. A venue 400 on legs (incompatible, redundant, not tradable) is `InvalidOrder` with the venue's message. Instrument state `OPEN` is `open`; every other state reads `closed`. +- **Sides.** `ask` = `buyPrice`, `bid` = `sellPrice`, both YES prices; "0" is a declined side (`None`). `acceptedSide` is the **requester's** side: buy YES or sell NO is `SIDE_BUY`, sell YES or buy NO is `SIDE_SELL`. Evidence: the schema says `buyPrice` is the "price offered for a requester BUY order; the quote creator takes SELL"; the trader guide maps `SIDE_BUY` to `buyPrice`/`buyQtyDecimal` and a maker sell; the takers page says a requester sell costs `1 − price`. This is the opposite of Kalshi's `accepted_side` (P0). +- **RFQs are two-sided** and have no expiry: `create_rfq` refuses `outcome`/`action`; an RFQ stays open until `cancel_rfq` or an accept. `amount` is sent as `cashOrderQty` (the venue sizes each side, taker fee included, and reports `buyQtyDecimal`/`sellQtyDecimal`). The venue returns only the id, so `create_rfq` returns the RFQ as sent. A closed RFQ has no reason on the venue, so `closed_reason` is `None`. +- **Combo or single market.** An RFQ is on a combo when it carries `comboLegs`, or, failing that, when its symbol starts `caoc-`/`crossd-` (the venue calls symbols opaque; this is a fallback). +- **Accept.** Re-reads the quote; refuses before sending if it is not `QUOTE_STATUS_ACTIVE`, the side is declined, or the price is worse than `worst_price`. No partial accept (`quantity`/`amount` refused). Returns the quote `accepted` without a second read. **Retry:** a quote already accepted on the same side is returned without resending, and the venue's requester `clord_id` (`rfq1.…`) is deterministic per quote and side, so neither layer can double-order. The idempotency key defaults to `rfq-accept:polymarket_us:{quote_id}` (kept in `info`; the venue takes no client id). `taker_order_id` is `rfqCreatorOrderId`, `maker_order_id` `creatorOrderId`; `executed` means submitted, not filled. +- **Statuses.** `ACTIVE`→`open`, `ACCEPTED`, `CONFIRMED`, `EXECUTED`, `DELETED`→`canceled` (withdrawn, replaced or declined in last look). The venue has no expired or failed quote status, so `fetch_quotes(status="expired"|"failed")` is refused. +- **Quoter.** `send_quote` sends `buyPrice` = our ask and `sellPrice` = our bid ("0" for a missing side), `restRemainder` and `postOnly`; the venue takes no quantity, expiry or client id, so those are refused. `replace_quote` posts again on the same RFQ (a new id; the venue deletes the old). `cancel_quote` also declines during last look; `confirm_quote` takes the accepted `RfqQuote`. `mine` comes from `GET /v1/rfqs/user-id`, read once. +- **Not built:** the RFQ WebSocket subscription (`SUBSCRIPTION_TYPE_RFQ`) and `GET /v1/rfqs/trades`. Per-endpoint pacing (GET `/v1/rfqs`, `/v1/rfqs/quotes` and `POST /v1/combos`+`/v1/rfqs` at 1 request/s) was added in P5. +- **UNVERIFIED, for P10:** the error body shape (assumed gRPC-gateway `{code, message}`); whether `RESOURCE_EXHAUSTED` (weekly combo quota) arrives as 429 (then retried as a rate limit) or 400; what status an accepted quote takes when the maker lets the last look lapse; whether `rfqId` + `userFilter` together are accepted (the code sends one filter); whether a crossed two-sided quote can exist (the model refuses `bid > ask`); whether a single-market (non-combo) RFQ is accepted on the retail API (its summary says "combo RFQ"). + +### P3 and P4 result (engine, offline, 2026-10-21) + +Built in `synpath/engine/rfq.py` (`RfqDesk`, attached as `engine.rfq`; the +`Engine` methods delegate to it), with `PaperRfqVenue` and `PaperMaker` in +`synpath/engine/paper.py` and tests in `tests/test_engine_rfq.py` (26, each +on SQLite and, with `SYNPATH_TEST_POSTGRES_DSN`, PostgreSQL). + +**Engine API.** + +| Side | Method | +|---|---| +| combos | `create_combo(legs, *, venue?) -> Combo`; `engine.rfq.combo(market_id)`; `engine.rfq.save_combo(combo)` | +| taker | `create_rfq(instrument_id, *, quantity \| amount, outcome?, action?, rest_remainder?, combo?, venue?, account?, book?, trader?, tags?) -> Rfq`, `cancel_rfq(rfq_id, *, venue?)`, `accept_quote(AcceptQuote, *, venue?, wait=False, timeout_s=30) -> RfqQuote` | +| quoter | `send_quote(MakerQuote, *, venue?) -> RfqQuote`, `replace_quote(quote_id, MakerQuote, *, venue?)`, `cancel_quote(quote_id, *, rfq_id?, venue?)`, `confirm_quote(quote \| quote_id, *, rfq_id?, venue?, wait=False, timeout_s=30) -> RfqQuote` | +| state | `engine.rfq.rfqs` (our open RFQs), `.maker_quotes` (our live quotes, with the engine-held expiry), `.incoming` (others' open RFQs), `.combos`, `.following` | +| driving | `engine.rfq.tick()` (one follower pass), `engine.rfq.on_quote(quote)` (a stream's entry point), the `rfq` loop in `Engine.background()` | + +**Events** (all journaled except `rfq.incoming`): `rfq.created`, `rfq.closed` +(`reason` canceled, executed, expired), `rfq.incoming`, `rfq.in_doubt`, +`quote.received` (a quote on our RFQ), `quote.sent` and `quote.replaced` (ours), +`quote.accepted`, `quote.confirmed`, `quote.executed`, `quote.canceled`, +`quote.expired`, `quote.failed` (an accept or confirm that ended without a +trade, with `reason`), `quote.in_doubt`, `rfq.side_mismatch`, `combo.saved`, +then the resulting order's `order.accepted` and `order.{status}` and its +`fill.booked`. Each quote event is keyed `{venue}:quote:{id}` and carries +`status`; a jump along open → accepted → confirmed → executed between two +reads publishes the steps in between, marked `inferred`. Refusals are +`risk.rejected` with `operation` `create_rfq`, `rfq_taker` or `rfq_quoter`. + +**Decisions.** + +- **One intent per accept or confirm, `sending` until the quote is final.** + No new intent state: the accept intent (`rfq-accept:{venue}:{quote_id}`, + or the caller's `client_order_id`) is written with `synchronous=FULL` before + the call and stays `sending` through the last look; it becomes `sent` + (with the order id) when the quote executes and the order is adopted, and + `failed` when the quote is canceled, expires or fails. So the follower, + a stream, `wait=True`, the sweep and `recover()` are one function + (`RfqDesk.resolve`), which reads the quote back. The detail + `accepted at the venue` / `confirmed at the venue` marks a call answered. +- **Recovery.** A quote still `open` (taker) or `accepted` (quoter) with no + answered-call mark after `lost_after_s` means the call never landed: the + intent is `rejected`, nothing traded, and the same accept may be made again + (the intent reopens). `accepted`/`confirmed` stay pending; `executed` adopts + the order; unreachable stays unresolved. Creates, cancels and quote sends in + doubt are read back too: an RFQ or quote of ours on the same instrument + created since the intent is adopted, otherwise `lost` after `lost_after_s`. + A retried accept or confirm never sends twice. +- **The resulting order goes through the normal order path**: journaled + with the intent's request (type `rfq_taker` or the new `rfq_maker`, book, + trader, outcome and action), `orders.on_order`, then its fills through + `on_fill` into the ledger. A fill that arrives (stream or poll) before its + order is adopted makes the desk resolve the followed quotes first + (`claim_fill`), so it is never booked unattributed. Tags `rfq_id` and + `quote_id` link the order to its quote. +- **Risk.** Accept and confirm run `Engine.check`, every order rule, on + the size and YES price that would trade (the price collar skips them, as it + skips market orders). `worst_price` is checked by the engine first (rule + `worst_price`). Creating an RFQ and sending or replacing a quote run + `RiskEngine.check_offer`: the kill switch, a paused book, the restricted + list, `max_order_contracts` and `max_order_notional` on the most the offer + could commit; position caps and the daily loss wait for the trade. A confirm + is booked on our side: it closes a holding where it can (`yes`/`sell` out of + a long), else opens (`default_leg`). +- **Amount at accept.** `contracts_for_amount` at the quote's price for the + token traded, down to the venue's step (the market master's precision, + else `venue_precision`), capped at the quote's size; an amount covering + the full size takes it whole. Below the venue minimum is refused before + sending. A venue without partial accepts (Polymarket US) then refuses a + partial size, so size such RFQs by amount at creation. +- **Side guard.** The order and fills are booked on the side the venue + reports. A mismatch with the intent (at the accept, on the quote, or on the + order) publishes `rfq.side_mismatch` once per intent; `Alerts` raises it as + a critical `rfq_side` alert; the order's outcome and action are cleared + rather than contradict its side. +- **Combos.** A combo is its own instrument in the ledger, positions and + reconciliation; its legs are `combo.saved` events (no schema change), + restored at start and shown as `Position.info["combo"]`. +- **Expiry is the engine's.** `MakerQuote.expires_at` is kept by the engine + and not sent (Polymarket US refuses one); the follower cancels the quote + when it passes (`quote.expired`, the cancel intent's detail `expired`). +- **Working sets survive restarts** in the journal's `cursors` table + (`rfq:open:{venue}`, `rfq:maker_quotes:{venue}`), not a new table. +- **Halt.** With the `cancel` policy, `halt()` cancels every open RFQ we asked + and every live quote we made, those the engine holds and those the venue + lists, on every venue in scope, before cancelling orders + (`result["rfq"]`). `hold` and `rearm` leave them up, as they leave orders; + accept, confirm, create and send are refused while halted, so an accepted + quote awaiting our confirm lapses. +- **Polling.** One `rfq` loop every `rfq_poll_interval_s` (1 s) follows + accepts and confirms, the quotes on our open RFQs and our own quotes; the + incoming feed polls every `rfq_incoming_interval_s` (2 s, `None` off) on + venues without an incoming stream. A venue with a + `subscribe_quotes(listener)` stream is subscribed at start, and one with + `subscribe_incoming_rfqs(listener)` is pushed, not polled (see "Gaps closed"). +- **Not built:** a venue RFQ stream for Polymarket US (P2 left it out), so it + is polled. Polymarket US's 1 request/s RFQ endpoints are paced in the + adapter since P5. + +### What P5 (serve routes) needs from P3/P4 + +- Routes call the `Engine` methods above; `POST .../accept` maps `wait` and + `timeout_s` straight through and answers 202 with the returned quote unless + it waited and the quote is final (200). `SubmissionInDoubt` is 202 with the + intent key, as for orders. +- `RiskRejected` carries `rule` (`worst_price`, `kill_switch`, `max_position`, ...) + for the 4xx body; `BadRequest` for a venue without the capability. +- `GET /rfqs` can read `engine.rfq.rfqs` plus the adapter's `fetch_rfqs`; + `GET /rfqs/incoming` reads `engine.rfq.incoming`; positions already carry + `info["combo"]`. +- `/ws/events` should forward the `rfq.*`, `quote.*` and `combo.saved` kinds; + `rfq.incoming` is not journaled, so a replaying client gets it live only. +- Grants: accept, confirm, create, send, replace and cancel are `trade` on the + venue's account; the reads are `view`. + +### P5 result (serve routes, offline, 2026-10-21) + +Built in `synpath/server/rfq_routes.py` (mounted by `create_trading_router`), +tested in `tests/test_server_rfq.py` (22 tests, against `PaperRfqVenue` beside +a plain paper venue). User docs: `docs/server.md`, "RFQs, quotes and combos". + +- **Paths.** Quote routes are flat (`/quotes/{id}/accept`, not + `/rfqs/{id}/quotes/{q}/accept`): the engine methods take a quote id, and + `rfq_id` rides in the body or query for venues that need it. `GET + /combos/collections` is not built (no typed method until P6; added in P6). +- **Venue of an id.** `venue` (query or body), the account, a Synpath market + id, the desk's working sets (`rfqs`, `incoming`, `maker_quotes`, + `following`), else the only venue with the capability; otherwise 400. + Grants are checked on that venue's account before any call. +- **Status codes.** Creates are 201 (`RfqView`, `RfqQuote`); `POST /combos` + is 200 (create or look up). Accept and confirm answer `QuoteResult` + (`quote`, `client_order_id`, `final`, `intent_state`, `order_id`): 202 by + default, 200 when `wait` and the quote is final. `SubmissionInDoubt` is the + existing 202 envelope with `details.client_order_id`. `RiskRejected` is 409 + with `details.rule`; `RfqNotFound` 404; `NotSupported` is now 501 on the + trading app too; a venue without the capability 400. +- **Lists** read the venue by default; `source=engine` serves the desk's own + state without a venue call (useful under Polymarket US's 1/s pace). +- **Halt.** `HaltResult` gained `rfq` (`rfqs`/`quotes` per venue). +- **Events.** `/ws/events` already forwarded every kind; its `kinds` filter + now applies to the replay too (it applied only to live events). + `rfq.incoming` stays live only. +- **Audit.** The audit log records grant and key changes only (triggers on + those tables); no trading route writes it. RFQ writes are journaled by the + engine, with `acting_as` set per request as for orders. +- **Pacing.** `PolymarketUSTrading` paces `GET /v1/rfqs`, `GET + /v1/rfqs/quotes`, and `POST /v1/rfqs` with `POST /v1/combos` (one shared + group) at 1 request/s, ahead of the shared 20/s limiter + (`PACED_ENDPOINTS`, `rfq_pace_per_second=None` turns it off); a queue + longer than 15 s refuses with `RateBudgetExceeded` before sending. +- **TypeScript.** The client's types are generated, not committed + (`npm run types`); the regenerated `synpath.d.ts` typechecks with the RFQ + schemas (`RfqBody`, `RfqView`, `AcceptBody`, `QuoteResult`, `MakerQuoteBody`, + `Combo`). + +### P0 result (Kalshi demo, 2026-10-21) + +**Taker side: settled.** +- `accepted_side` names the side of the maker's bid being taken, not the taker's outcome. +- **To buy YES, send `accepted_side=no`.** The price is 1 − the maker's `no_bid`. To buy NO, send `yes`; the price is 1 − `yes_bid`. +- Evidence: + - A quote with only a NO bid (`yes_bid 0.00`, `no_bid 0.592`) refused `accepted_side=yes` as invalid parameters. + - A quote with `yes_bid 0.45` / `no_bid 0.55`, accepted with `no`, went to `executed`. The fill was `side yes, action buy, 5 @ yes_price 0.45, is_taker true` on `rfq_creator_order_id`, and the position became +5 YES. +- Combo RFQs on demo get quotes within seconds. An unrequested RFQ expires on its own (its delete answered "expired"). + +**Quoter side: still open.** +- `POST /communications/quotes` (13 contracts at 0.01/0.01) returned 400 `insufficient_balance` with $95.28 of demo funds. +- The collateral rule for quotes is unverified. It may need more demo funds, or a maker permission the error does not name. +- Needs a funded demo account to finish. + +The demo account kept 5 YES on `KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW` ($2.33 of demo funds including fees). No real money was used. + +### P6 result (Kalshi, offline, 2026-10-21) + +Built in `synpath/trading/kalshi.py`, tested offline in `tests/test_kalshi_rfq.py` +(samples `tests/samples/kalshi_rfq_*.json`, built to the OpenAPI v3.34.0 schemas +and the P0 shapes; nothing captured live). `rfq_taker`, `rfq_quoter` and +`combos` are True; `rfq` stays True for one release. Decisions: + +- **Combos.** A combo is a `KXMVE…` market, so its id is `kalshi:` + and its legs come from `mve_selected_legs` (`ComboLeg.event_id` is the bare + `event_ticker`). `create_combo(legs, collection_id=None)` posts + `selected_markets` with `with_market_payload`; without `collection_id` it + picks the one open collection whose `associated_events` take every leg's + event at that size (none is `InvalidOrder`; several is `InvalidOrder` + naming them, on `exc.collections` and served as `details.collections`), and + reads a leg's market when `event_id` is missing. `engine.create_combo` and + `POST /combos` pass `collection_id` through. 5,000 creations a week; a 400 on legs is + `InvalidOrder`. `fetch_combo` reads `GET /markets/{ticker}`; a market + without selected legs is `RfqNotFound`. `lookup_combo` is `NotSupported` + (no lookup endpoint left). `fetch_combo_collections` (Kalshi only) lists + collections as the venue's objects, each with `associated_event_tickers` + (filled from `associated_events` if the venue leaves it out), served at + `GET /combos/collections`. +- **Taker.** `create_rfq` sends `contracts_fp` or `target_cost_dollars` + (`target_cost_excludes_fees` optional, amount only; `replace_existing` + optional) and refuses `outcome`/`action`. An RFQ with a target cost reads + back as `amount`. `fetch_rfqs` is `user_filter=self`; `fetch_quotes` sends + `rfq_user_filter=self` with `rfq_id` and also drops other RFQs' rows. + `cancellation_reason` is free text, read into `closed_reason` where it says + expired, executed, replaced or cancelled. +- **Accept.** The P0 mapping: buy YES (or sell NO) sends `accepted_side=no` + at `1 − no_bid`; buy NO (or sell YES) sends `yes` at `1 − yes_bid` a NO + contract. A reported `no` reads as `Side.BUY`. Refused before sending when + the quote is not open, the side's bid is 0, or the price is worse than + `worst_price`; full size only (REST takes no partial); a retry on the same + side returns the quote without resending. `taker_order_id` is + `rfq_creator_order_id`, `maker_order_id` `creator_order_id`. +- **Quoter.** `send_quote` posts `yes_bid` = bid, `no_bid` = 1 − ask ("0.0000" + for a missing side), `rest_remainder`, `post_only`; quantity and expiry are + refused (the engine keeps expiry). `fetch_my_quotes` uses `user_filter=self`, + the v3.34.0 filter for quotes the caller made (`quote_creator_user_id` is + deprecated). `replace_quote` is DELETE then POST: for one round trip the RFQ + has no quote of ours, and an old quote already gone does not stop the send. +- **Statuses.** Quote `open`, `accepted`, `confirmed`, `executed`, `cancelled` + (→ `canceled`); no expired or failed status, so those filters are refused. +- **Errors.** A 404 under `/communications` or `/multivariate_event_collections` + is `RfqNotFound`; `insufficient_balance` is `InsufficientFunds`; other 400/409 + are `OrderRejected` with the venue's code. +- **Engine.** One end-to-end test runs `engine.rfq` on the Kalshi adapter with + mocked HTTP: combo RFQ, accept YES (sent as `no`), quote executed, order + adopted, fill booked, position long 5. +- **UNVERIFIED, for P10:** the quoter's collateral rule (P0's 400 + `insufficient_balance` with $95 for 13 contracts at 0.01/0.01); confirm on + the demo; which of `yes_contracts_fp`/`no_contracts_fp` is which side on a + target-cost RFQ (read here as YES/NO contracts the requester can buy); + whether `rfq_id` with `rfq_user_filter=self` is accepted together; the + `cancellation_reason` words; whether recreating an existing combo uses the + weekly quota, and whether that quota answers 429 (retried as a rate limit) + or 400; and the timing of `rfq_creator_order_id` and fills after executed. + + +### P8 result (SX Bet, offline, 2026-10-21) + +Built in `synpath/trading/sx_bet.py` (`SxBetTrading`) and `synpath/ws/sx_bet.py` +(`SxBetParlayStream`), tested offline in `tests/test_sx_bet_parlays.py` (sample +`tests/samples/sx_bet_parlay_request.json`, the channel page's example). +`rfq_quoter` and `combos` are True; `rfq_taker` stays False. Decisions: + +- **Requests.** Each `parlay_markets:global` message is an incoming `Rfq`: `id` the parent hash, `combo_id` `sx_bet:{parent}`, `combo` the legs (`yes` where `bettingOutcomeOne`), two-sided (`outcome`/`action` `None`), `amount` = `requestSize` / 1e6, `mine=False`. A request with fewer than two distinct legs is dropped. The venue replays nothing and has no REST list of open requests, so the adapter keeps what its stream heard (`SxBetTrading.parlays`, ten minutes after the end); `fetch_incoming_rfqs` returns the open ones and starts the stream on its first call. `subscribe_incoming_rfqs(listener)` is the stream hook; the engine subscribes there at start and does not poll SX Bet (`engine.rfq.on_incoming_rfq`, "Gaps closed"). +- **Expiry.** The request names no end, so it is estimated as 60 s from arrival (`expires_at`, `info["expiry_exact"]` False) and orders are signed to 3 s before that (`PARLAY_EXPIRY_MARGIN_S`), or to `MakerQuote.expires_at` if sooner (the engine keeps its own and cancels at it). A `PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY` refusal carries the exact end; the quote is re-signed to it once and the request marked exact. An ended request is `closed`/`expired` and refused before anything is sent. +- **Quotes.** A quote is one order per side on the parlay market: the bid on outcome one at `bid`, the ask on outcome two at `1 − ask` (the adapter's usual buy/sell mapping), venue `GTC` with the expiry above, `postOnly` from `post_only`, client ids `{key}:bid`/`{key}:ask`. No `quantity` sizes each side to the requested stake at the bettor's price (`amount / ask` contracts on the ask, `amount / (1 − bid)` on the bid). The quote id names both orders (`0xbid~0xask`), so cancel and read-back need no local state. Status from the orders: `open` while one rests, `executed` when one filled and none rests (`maker_order_id` the filled one), `expired` if both expired, else `canceled`. One side refused leaves a one-sided quote (`info["refused"]`). `replace_quote` is cancel then send (a new id). `confirm_quote` is `NotSupported`: no last look. `fetch_my_quotes(rfq_id=)` also pairs resting orders on the parlay by client id, so a send in doubt is found again. +- **Combos.** `fetch_combo` and `lookup_combo` read the requests heard; `create_combo` is `NotSupported` (no API requests a parlay). Parlay fills are no longer dropped: their `marketHash` is the parent, so they book on the combo's id. +- **Void legs.** The two documents disagree (§1). `parlay_result(legs, outcome_one=)` returns both readings and the `worst` for the side held; nothing is booked from a leg result, and the venue's settlement is the record. For risk, a VOID leg is assumed to lose the stake on either side, never to refund it. +- **Errors.** `INVALID_MARKET` on a quote is `OrderRejected(reason="parlay_expired")`, `PARLAY_LEG_INACTIVE` is `OrderRejected(reason="parlay_leg_inactive")`, the expiry refusal (if the retry fails) and size refusals are `InvalidOrder`; the rest as for any SX Bet order. +- **Not built:** quote status from the account's order stream (the engine polls `fetch_my_quotes`); a fee estimate at the parlay rates (`makerParlayPayoutFee`, `takerParlayPayoutFee`); leg prices for pricing (`GET /markets/find`, `/orders-v3/odds/best`) are the quoter's own business. +- **UNVERIFIED, for P10:** how late requests arrive against the 60 s (is 3 s enough?); whether `requestSize` is always USDC (6 decimals) and whether the bettor always backs outcome one; that a parlay order's `expiry` reads back as ISO; that parlay fills and positions come through `/fills-v3` and `/positions-v3` on the parent; how the venue settles a VOID leg in practice; whether a maker's ask and bid on one parlay can both fill. + +### P7 result (Polymarket global, offline, 2026-10-21) + +Built in `synpath/trading/polymarket_v3_signing.py` (Exchange v3 orders) and +`synpath/trading/polymarket_rfq.py` (`PolymarketRfqMixin` on +`PolymarketTrading`, and `PolymarketRfqSession` for the quoter WebSocket). +Tested offline in `tests/test_polymarket_v3_signing.py` (vectors in +`tests/samples/polymarket_v3_signing_vectors.json`) and +`tests/test_polymarket_rfq.py` (samples `tests/samples/polymarket_rfq_*.json`, +the documented shapes; one engine run from create to position). `rfq_taker`, +`rfq_quoter` and `combos` are True on `PolymarketTrading`. Decisions: + +- **Signing.** The Exchange v3 `Order` has the same eleven fields as CLOB V2, + under the domain `{name "Polymarket CTF Exchange", version "3", chainId 137, + verifyingContract 0xe3333700cA9d93003F00f0F71f8515005F6c00Aa}`. There is + one Exchange v3 and no neg-risk variant. `timestamp` is in seconds; `side` + is `0`/`1` on the wire. Types 0, 1 and 2 sign the order directly (65 + bytes). A Deposit Wallet (3) signs the `TypedDataSign` envelope (its + `DepositWallet` v1 domain, the wallet as `verifyingContract`, zero salt), + wrapped for ERC-7739. The digest is signed by `WalletSigner.sign_digest`, + which uses the Rust core's secp256k1 signer when present. The venue + publishes no v3 vectors; py-sdk's golden tests cover V2 only. The vectors + were therefore made without Synpath code: the docs' typed data was signed + by `eth_account`, and the docs' `wrapDepositWalletSignature` was rewritten + with `eth_abi`. Synpath matches them byte for byte on both signers, and + every signature recovers to the signer. The CLOB V2 vectors still match. +- **Wallets.** The requester gateways refuse EOAs + (`UNSUPPORTED_REQUESTER_SIGNATURE_TYPE`). This is also checked locally, + before anything is sent, with a message that says what to change. Accepted: + Deposit Wallet (signer = maker = the wallet), proxy or Safe (signer = the + account key, maker = the wallet). Quoting accepts all four types. Combos + need no key beyond the CLOB API credentials, and both gateways take the L2 + headers. A quoter needs `approve_combo_trading()` (pUSD and PositionManager + to Exchange v3; `include_router` and `include_auto_redeem` add the + Router and AutoRedeemer approvals, "Gaps closed"). Last look needs the venue's opt-in. +- **Combos.** The venue has no combo create or lookup; an RFQ names the + combo. A combo id is `polymarket:combo-` once an RFQ has + returned it. Before that, it is the provisional `polymarket:combo-legs-.` + (the leg position ids, ascending), which `create_rfq` reads with no state. + `create_combo` is offline and uses no quota. Leg position ids are not CLOB + token ids. They come from the public catalogue (`fetch_combo_markets`, and + typed as `fetch_combo_eligible_markets`, served at `GET /combos/markets`), + `leg.info["position_id"]`, Gamma `/markets/{id}` `positionIds`, and, in + reverse, Gamma `/markets/keyset?position_ids=`. `lookup_combo` is + `NotSupported`. +- **Sides.** An RFQ asks one direction on the combo's YES (`outcome="yes"`; + the venue's `side` is YES only). A buy is sized by `amount` (pUSD, fees + included, `notional`) and a sell by `quantity` (shares). The gateway's one + quote is `ask` = `blended_price_e6` on a buy (`ask_quantity` = the shares + received, `taker_amount_e6`) and `bid` on a sell (`bid_quantity` = + `maker_amount_e6`). Accept signs `tokenId = yes_position_id`, side 0 (buy) + or 1 (sell), and the quote's exact `maker_amount_e6`/`taker_amount_e6`, + with a zero builder. As a quoter, the request's side decides which price + counts: a buy request takes our `ask` and is filled by buying NO, + `ceil((1 − ask) × size)`; a sell request takes our `bid` and is filled by + buying YES, `ceil(bid × size)`. The full size is + `floor(notional × 1e6 / price)`. Inventory quotes (selling held tokens) + are not built. +- **Worst price.** Accept is refused before signing if the blended price or + the order's own price (`maker/taker`, to the base unit) is worse than + `worst_price`. Fees come on top (`info["total_required"]`). Only the full + size can be accepted. +- **Statuses.** An RFQ's quote reads `CREATED`/`COLLECTING_QUOTES`/`AWAITING_REQUESTER_ACCEPTANCE` + → `open` (`expired` after `expires_at`), `AWAITING_MAKER_CONFIRMATION` → + `accepted`, and `EXECUTING`/`MATCHED`/`MINED`/`RETRYING` → `confirmed` (on + its way to the chain, not a fill). `FILLED`/`CONFIRMED` is `executed`; + `FAILED`/`REJECTED` is `failed` (`MAKER_DECLINED` is `canceled`). + `EXPIRED` and `CANCELED` map as named. Once an RFQ ends without a trade, + `fetch_quotes` raises `RfqNotFound`, so the engine closes it. Our quotes + as a quoter move `open` → `accepted` (`RFQ_CONFIRMATION_REQUEST`) → + `confirmed` → `executed`/`failed` (`RFQ_EXECUTION_UPDATE`). A quote is + read as lost (`canceled`) on an `RFQ_TRADE` broadcast with no execution + of ours, and as `expired` 30 s after sending if never selected. +- **Orders and fills.** The trade settles on chain, not on the CLOB. Its + orders have synthetic ids: `rfq:` for the requester and + `rfq::` for a quoter. `fetch_order` and + `fetch_my_trades` (on a combo id or such an order id) answer them from + the RFQ: filled on `executed`. The taker fee is `total_required − + maker_amount` on a buy and `taker_amount − net_receive` on a sell. +- **What the gateway lacks.** It has no list of a requester's RFQs and no + requester cancel, and status reads answer 409 before an accept. So the + adapter keeps the RFQs it created in this process: `fetch_rfqs` lists + them, `cancel_rfq` drops one locally (it lapses at `expires_at`), and + reads come from the create answer until the accept. `remember_rfq(rfq)` + re-seeds an RFQ after a restart from `Rfq.info`, which the engine keeps + in its working set; the engine calls it through `restore_rfqs` at start + ("Gaps closed"). +- **Create retries.** Create takes no idempotency key. A create that timed + out may have made an RFQ whose id was never returned. That RFQ cannot be + accepted, so nothing trades, and it lapses within seconds; a retry may + make a second one, at the cost of the 15-a-minute create budget. Guards: + the engine's journaled intent and `SubmissionInDoubt`, and `create_rfq` + returning an open RFQ it already holds for the same legs, direction and + size. +- **Retries of accept.** An accepted quote is returned without sending + again, and the gateway does not execute one acceptance twice. A 5xx + (`SERVICE_UNAVAILABLE`, `TRADE_SUBMISSION_FAILED`) stays + `ExchangeNotAvailable`, which the engine treats as in doubt. +- **Errors** (the `{error, code}` body): `UNKNOWN_RFQ` → `RfqNotFound`; + the EOA code and other 400s → `InvalidOrder`; `EXPIRED_RFQ`, + `INVALID_RFQ_STATE`, `QUOTE_MISMATCH`, `INVALID_SIGNATURE`, the quote + validation codes and similar → `OrderRejected(reason=code.lower())`; + `PRE_EXECUTION_BALANCE_RESERVATION_FAILED` and the balance and allowance + codes → `InsufficientFunds`; 401/403 → `AuthenticationError`; 429 → + `RateLimitExceeded`. +- **Quoting.** Quote requests arrive only on + `wss://combos-rfq-gateway-quoter.polymarket.com/ws/rfq`. + `start_rfq_session(on_rfq=)` opens it: auth comes first with the CLOB + credentials and the identity; the library answers pings. Quotes go over + REST (`POST /v1/maker/quotes`, with our own `quote_` + 32-hex id derived + from the engine's key, and `valid_until` from `expires_at`). Cancel is + `POST /v1/maker/quotes/cancel`. While a last look is open, `cancel_quote` + sends `DECLINE` to `/v1/maker/confirmations`, and `confirm_quote` sends + `CONFIRM`. `replace_quote` is cancel then send. A buy quote above 0.90909 + is refused locally. The window is 400 ms; the engine hears each request + from the session as it lands (`engine.rfq.on_incoming_rfq`, "Gaps closed"). +- **Not built:** the Builder gateway (`/v1/builder/rfq/requests`, Builder + API key), inventory quotes, collateral return, combo positions from the + Data API, quoting over the WebSocket (`RFQ_QUOTE`; REST is used), and + Session Keys (py-sdk refuses them for combos). +- **UNVERIFIED, for P10:** whether `taker_order_hash` equals our Exchange v3 + order hash (a mismatch is logged); the acceptance window (5 s or 10 s); + whether `GET /requests/{id}` carries more than `{rfq_id, status, tx_hash}`; + the fee arithmetic above; whether a requester also needs the Exchange v3 + approvals; Gamma `positionIds` on `/markets/{id}` and the shape of + `/markets/keyset`; the quoter fill size when the bundle splits across + makers (`fill_size_e6` is used when sent); the REST maker snapshot's + status words; and whether the requester's Deposit Wallet signer may be a + session key. + +### Gaps closed (2026-10-21, offline) + +**1. Incoming RFQs are pushed, not polled every 2 s.** + +- `engine.rfq.on_incoming(venue, rfq)` is the one entry point for an + incoming RFQ, from a stream or the poll. In order, it records the RFQ in + `engine.rfq.incoming`, publishes `rfq.incoming` (not journaled), then runs + every callback registered with `engine.rfq.on_incoming_rfq(callback)`, + awaited. The combo's legs (`combo.saved`) are journaled after the + callbacks, and `send_quote` now saves them after its venue call. An RFQ + already known, or one of ours, is not heard twice. +- New optional hooks on `TradingExchange`: `subscribe_incoming_rfqs(listener)` + returns the unsubscribe, or `None` (the default) where there is no stream; + `start_incoming_rfqs()` starts the stream (default no-op). +- At start (`recover()`), the desk subscribes every quoting venue whose hook + returns an unsubscribe. Those venues are in `engine.rfq.pushed` and are + left out of the 2 s poll. Their streams are started by the `rfq` loop (and + by `tick()`), and once started, what they already heard is taken. A stream + that will not start falls back to the poll. `rfq_stream_incoming=False` + leaves starting them to the caller, and `rfq_incoming_interval_s=None` + still turns the whole feed off. Pushed RFQs leave `incoming` a minute + after their window ends. +- **Polymarket global:** the quoter session tells the adapter's listeners + first (scheduled as tasks, so the socket keeps reading), then `on_rfq`. + `start_incoming_rfqs()` opens the session. +- **SX Bet:** `subscribe_incoming_rfqs` as before; `start_incoming_rfqs()` is + `watch_parlays()`. +- **Kalshi and Polymarket US:** neither `synpath/ws/kalshi.py` nor the + Polymarket US streams have an RFQ channel, so both keep the poll. +- **The send path:** `send_quote` reads the RFQ from `engine.rfq.incoming` + (no venue read), runs `check_offer` (in memory), writes the intent, and + calls the venue. +- **Timing (in process):** + - `PaperRfqVenue(stream_incoming=True)`: push to callback 0.05 ms; push to + the quote sent through `engine.send_quote` about 4 ms (SQLite, with the + journal writes). + - Polymarket's session (`RFQ_REQUEST` to the callback, then a mocked + `POST /v1/maker/quotes`): 0.3 ms and about 5.5 ms. + - The tests assert under 50 ms to the callback and under 400 ms to the quote. + - `/ws/events` reads the bus subscription's queue directly, so a pushed + `rfq.incoming` reaches a connected socket with no poll; a test asserts it. + +```python +async def quoter(rfq): # runs inside the window: 400 ms on Polymarket global + if rfq.venue != "polymarket" or rfq.action != "buy": + return + await engine.send_quote(MakerQuote(rfq_id=rfq.id, ask=Decimal("0.42"), book="mm", + expires_at=rfq.expires_at), venue=rfq.venue) + +stop = engine.rfq.on_incoming_rfq(quoter) # stop() unsubscribes +``` + +**2. Polymarket global remembers its RFQs after a restart.** + +- New optional hook: `TradingExchange.restore_rfqs(rfqs, quotes)`, a no-op by + default. `RfqDesk.restore()` (called from `recover()`, before the in-doubt + intents are resolved) passes each adapter: + - our open RFQs from the working set (`Rfq` with its `info`). An RFQ with + an in-doubt accept that has left the working set is rebuilt from its + `rfq.created` event. + - a quote for each in-doubt accept (`mine=False`, `accepted`, the + intent's side); + - for each in-doubt confirm, our quote from its latest `quote.sent` / + `quote.accepted` / `quote.confirmed` event (`mine=True`), at `confirmed` + if the confirm was answered, else `accepted`. +- `PolymarketTrading.restore_rfqs` calls `remember_rfq`. For an accept, it + marks the RFQ accepted, so its status is read from `GET /requests/{id}`: + - FILLED adopts the order and books the position; + - FAILED is `quote.failed`; + - a 409 (the accept never landed) reads `open`, and after `lost_after_s` + the intent is `rejected` with nothing sent; + - a retried accept on such an RFQ sends again. +- Our quotes are re-seeded at their status, with the requester's + `fill_size_e6` and `confirm_by`. A confirm in doubt therefore stays + pending, instead of reading as unknown and failing. Its execution still + comes only from the quoter session (`RFQ_EXECUTION_UPDATE`); the gateway + replays nothing. +- Tests (`tests/test_polymarket_rfq.py`, mocked HTTP, SQLite): + - create, accept and crash, then rebuild on the same journal: the accept + resolves, the order `rfq:` is adopted, and the position is 1.932381; + - an accept that never landed is `rejected` and not resent; + - an open RFQ is cancelled after a restart; + - a confirm in doubt is followed to execution and a position of 2. + +**3. Polymarket global approvals.** + +`approve_combo_trading(include_router=False, include_auto_redeem=False)` and +`v3.combo_approval_calls(...)`: + +| Approval | Role (market-makers guide) | Sent | +|---|---|---| +| pUSD `approve(Exchange v3)`, PositionManager `setApprovalForAll(Exchange v3)` | quoter, when the quoted order moves pUSD or combo positions | always | +| pUSD `approve(Router)`, PositionManager `setApprovalForAll(Router)` | splitting pUSD into positions; managing or redeeming them through the Router (optional) | `include_router` | +| PositionManager `setApprovalForAll(AutoRedeemer)` | automatic redemption (optional) | `include_auto_redeem` | + +- Router is `0x12121212006e4CD160D18e3f00711DA5c3372600`; AutoRedeemer is + `0xa1200000d0002264C9a1698e001292D00E1b00af`. +- **Requesters: UNVERIFIED.** The guide names no approvals for them. A BUY + spends pUSD and a SELL moves combo positions, so the Exchange v3 pair is + probably needed; P10 settles it. +- The calldata is tested byte for byte: the selectors `0x095ea7b3` and + `0xa22cb465`, the spender word, and max uint / true. + +## 9. Settled questions (2026-10-21) + +1. P0 on the Kalshi demo: yes, with notice before it runs. +2. Order: Polymarket US first (P2), then Kalshi, Polymarket global and SX Bet in parallel after the serve layer. +3. Quoter side: in scope now (P4, P6–P8). +4. Amount sizing: yes, venue-native where possible, otherwise converted at accept. +5. Polymarket global: yes, including Exchange v3 signing (P7). +6. Accept returns at once; `wait` is optional. +7. Limitless Packs: deferred (not in scope). + +## 10. Cross-venue RFQ + +Built 2026-10-22, offline (Yuki approved, Richard OK'd). One request goes to +every venue that lists the same market or combo, every quote lands in one +ranked list, and an accept cancels the rest. Code: +`synpath/engine/rfq_group.py` (`engine.rfq_groups`), routes in +`synpath/server/rfq_routes.py`. Each member is an ordinary RFQ of the desk +(§5): created, watched, accepted, followed and recovered exactly as one +asked directly. + +### Instrument resolution + +| Asked on | Members | +|---|---| +| `bucket:` | one per enabled bucket member, on its market, with its `flip`; a switched-off member is skipped (`member_disabled`) | +| `legs` (a combo of propositions; each leg any venue's market or a bucket), or a combo's own id the engine holds legs for | every venue with `combos` and `rfq_taker`: each leg mapped (itself on its own venue, a bucket's member there with its flip, else hosted matching with its `side_map`), then checked, then that venue's combo created (Kalshi with the `collection_id` the check chose) | +| a plain market | its own venue; with `cross_venue=True` also every other RFQ venue's match (`match_market(anchor, venue=v)`), its `side_map` giving `flip`. Without it, a group of one | + +- **Matching** is `engine.rfq_groups.matcher(anchor, venue)`, by default the + hosted service (`synpath.matching.match_market`, run in a thread). `Engine(..., + matcher=...)` replaces it. A leg whose venue `side_map` swaps YES and NO is + asked on the other outcome there. +- **Eligibility.** Kalshi (an adapter with `fetch_combo_collections`): the leg + events (`event_id`, else the market's own `event_ticker`) must all be in + one open collection's `associated_event_tickers`, within `size_min` and + `size_max`; the first such collection (sorted) is the `collection_id`. + Polymarket and Polymarket US (an adapter with its own + `fetch_combo_eligible_markets`): every mapped leg must be on the list, read + up to 20 pages and kept 60 s; Polymarket legs get their + `info.position_id` from it. A venue with neither is `unchecked`. +- **Skipped venues**, each with `venue`, `code`, `reason` and the first leg: + `excluded` (`venues`, `exclude_venues`), `forbidden` (the HTTP key may not + trade it), `not_configured`, `no_rfq`, `no_combos`, `no_match`, + `matching_error`, `missing_leg`, `ineligible_leg`, `outside_collections`, + `duplicate_leg`, `one_sided_size` (Polymarket global asks a YES buy by + amount and a YES sell by quantity), `member_disabled`. A combo venue names + every leg it lacks in `missing_legs` (`market_id`, `label`, `code`, + `mapped_id`, `detail`) and its `reason` names them by label + ([server](server.md#combo-skip-reasons)): no match (`missing_leg`), + matching unavailable (`matching_error`), matched but not combo-eligible + there (`ineligible_leg`), or outside the venue's combo collections + (`outside_collections`, Kalshi). Labels come from + `engine.rfq_groups.labeler` (serve sets its combo board warmer's). No venue left is `BadRequest` with the + list on `exc.skipped` (`400 no_venue` over HTTP), and nothing is journaled. +- **One-sided venues.** An adapter with `rfq_one_sided = True` (Polymarket + global) is asked the YES side the group takes there; two-sided venues are + asked no side. + +### The model: `RfqGroup` + +`id`, `instrument` (`kind` market/bucket/combo, `market_id`, `legs`), +`quantity` or `amount`, `outcome` and `action` (the requester's, in the +instrument's terms), `cross_venue`, `book`, `trader`, `tags`, `members`, +`skipped`, `quotes` (ranked), `status`, `accept_attempts`, `claim_key`, +`accepted_quote_id`, `accepted_venue`, `order_id`, `failures` (venue, +stage, error), `expires_at` (the soonest venue expiry among the quotes that +can be accepted now), `created_at`, `updated_at`. + +- `GroupMember`: `venue`, `instrument_id`, `flip`, `via` + (anchor/bucket/matching/combo), the mapped `legs`, `combo`, + `collection_id`, `eligibility`, `create_key`, `rfq_id`, `rfq`, `status`, + `detail`, `accept_key`. Status: `creating` (also an in-doubt create), + `open`, `failed`, `canceled`, `lapsing` (no requester cancel: left to + expire), `closed`, `accepting`, `accepted`, `executed`. +- Group status: `creating` → `open` → `accepting` (claimed, cancelling) → + `accepted` (sent, followed) → `executed`; or `canceled`, `expired` (no + member left), `failed` (every create failed). A failed accept goes back to + `open`. + +**Journal.** The intent `rfq-group:{id}` (`rfq_group_create`) is written +before any venue call, then each member's create under its own key +`rfq-create:{venue}:{group}-{n}` (passed to `create_rfq(key=...)`). The +group is saved to the cursor `rfq:groups` before each venue call and after +each answer, and published as `rfq_group.*` events with the whole group in +`payload.group`. A create that fails marks that member `failed`; the group +stays open with the rest, and the create intent is `sent` (`rejected` when +no member stands). + +### Ranking + +Every quote becomes a `GroupQuote` in the group's terms. With `S` the +group's YES side (`yes_side(outcome, action)`) and a member's side `S'` +(`S`, or the opposite for a flipped member): + +``` +raw = quote.ask if S' is buy else quote.bid # the member's own YES price +price = raw, or 1 - raw for a flipped member # the group's YES price +token = price for outcome yes, 1 - price for outcome no +fee = the venue's taker fee for one order of `quantity` at `raw` on `S'` + (adapter.fetch_fee_estimate: the venue's fee schedule; unknown counts as 0) +per = fee / quantity +effective_price = price + per (YES buy) | price - per (YES sell) +effective_outcome_price = token + per (action buy) | token - per (action sell) +``` + +- Quantity-sized: `quantity` is the group's (the quote's size if smaller: + `partial`). Rank by `effective_outcome_price`, lowest first for a buy, + highest first for a sell. +- Amount-sized buy: `quantity` is the most contracts with + `quantity * token + fee(quantity) <= amount`, on the venue's step (sized, + then re-sized net of the fee until it fits). Rank by `quantity`, most + first, then by `effective_outcome_price`. An amount-sized sell ranks as a + quantity-sized one. +- Quotes that cannot be accepted now (not open, their member not open, no + price on the side asked, no size) follow, with `rank: null`. +- Each change to a member's quote (from the desk's follower, a venue + stream, or `refresh`) publishes `rfq_group.quote` (the `GroupQuote`, with + `group_id`) and then `rfq_group.updated`. + +### Quote lifetime (2026-10-22) + +There is no window of the engine's or of a caller's. A `GroupQuote` carries +the venue's own deadlines: `expires_at` (the quote's `expires_at`, else its +member RFQ's; `expires_from` says which), `confirm_by` and `execute_by` +(after an accept). Where the venue gives none, it is null and the quote is +live until the venue closes it: + +| Venue | Quote expiry | RFQ expiry | +|---|---|---| +| Polymarket global | `expires_at` (the gateway's) | `expires_at` | +| Kalshi | none in the API (`Quote` has no expiry field) | none (`RFQ` has no expiry field; the venue closes RFQs itself, `cancellation_reason: expired`) | +| Polymarket US | none (`confirmationDeadline` / `executionDeadline` after an accept) | none | + +- The desk's follower re-reads each open RFQ of ours from its venue every + `RFQ_STATUS_INTERVAL_S` (10 s), and at once once its `expires_at` has + passed; an RFQ the venue has closed is closed here (`rfq.closed` with the + venue's reason), its open quotes `expired` (or `canceled`). +- A group is `expired` when every member RFQ has closed or expired on its + venue; a caller cancels it at any time (`DELETE`). +- Quotes and status changes reach a caller as `rfq_group.*` (and the members' + `quote.*`) events on `/ws/events`; polling is only a fallback. +- `PaperRfqVenue(rfq_ttl_s=...)` and `PaperMaker(ttl_s=..., delay_s=...)` + give paper RFQs and quotes an expiry and a delay, for tests and demos. + +### Accept: one per group, the rest cancelled + +`engine.accept_group_quote(group_id, quote_id, venue=None, worst_price=None, wait=False)`: + +0. The quote is named by `(venue, quote_id)`: quote ids are the venues' own, + and two venues may return the same one. `venue` is required when the group + spans more than one venue (`BadRequest`, reason `venue_required`; over HTTP + `400 venue_required`); a venue outside the group is `venue_not_in_group`. + The claim's journal row records the venue, and is read back against it. +1. Under the group's lock, the group is reconciled with the journal and + refused if an accept is in flight or done (`RiskRejected`, rule + `rfq_group_accept_in_flight` or `rfq_group_accepted`). +2. `RfqDesk.check_accept` runs everything the accept will check (the quote + re-read, `worst_price` turned over for a flipped member, the size, every + order risk rule, the halt) without writing or sending. A refusal leaves + every venue's RFQ standing. +3. **The claim**: the intent `rfq-group-accept:{group}:{n}` + (`rfq_group_accept`, `target_order_id` the quote). Attempt `n` is written + only when every earlier one is `failed`, `rejected` or `lost`, and is + read back after the write (an insert that loses a race reads the + winner's quote and is refused). Its params name the member accept's key + and the other members to cancel. +4. **Cancel every other member first** (`RfqDesk.cancel_rfq`, journaled). + A venue whose requester cannot cancel (`rfq_requester_cancel = False`, + Polymarket global) is dropped and left to lapse at its expiry: member + `lapsing`, with the reason. A cancel that fails is recorded in + `failures`; nothing trades on an RFQ that is never accepted. +5. The claim is marked `sending`, and the quote accepted through + `RfqDesk.accept_quote(..., _group=id)` under the key + `rfq-accept:{venue}:{quote_id}`: risk, the journal, following, recovery. +6. The group follows the member accept's intent: `sent` (executed) makes the + group `executed` and the claim `sent`; `failed`/`rejected` releases the + claim (`failed`), and the group is `open` again on what is still open + (`expired` if nothing is). What was cancelled stays cancelled. + +**Guarantees.** + +- At most one claim per group is ever live, by the journal (step 3), not by + memory: a restarted engine, or one whose in-memory group is wrong, still + refuses a second accept. +- A quote on a member RFQ cannot be accepted around the group: + `RfqDesk.accept_quote` (and `POST /quotes/{id}/accept`) refuses it. +- The other venues are cancelled before the accept is sent. + +### Restarts, halt + +- `recover()`: `RfqDesk.restore()`, then `RfqGroupDesk.restore()` reads + `rfq:groups` and re-seeds any open member RFQ the desk's working set lacks + (`restore_rfqs`); then the in-doubt intents (group intents included); then + every group is reconciled. A member create in doubt joins its group when + the desk adopts it (it is cancelled at once if the group was accepted + elsewhere meanwhile). A claim whose member accept was never written (the + process died between the cancels and the accept) finishes the cancels it + listed and is released: nothing traded, the group is open, a retry is + attempt `n + 1`. A claim whose accept was sent is followed by the desk. +- `halt()` with the `cancel` policy cancels the members in scope of every + open group first (`result["rfq"]["groups"]`); a group left with no open + member is `canceled`. An accept in flight is left to finish. Under any + policy, a new group accept is refused at step 2. +- A group every venue refuses before sending (the halt, a paused book, a + size limit) is journaled `failed` and the refusal raised (`409` over + HTTP). + +### Preview and the combo board (2026-10-22, for the terminal's Combos screen) + +- `engine.preview_rfq_group(...)` (`RfqGroupDesk.preview`) runs a create's planning (instrument, + matching, eligibility, one-sided sizes, refusals) and returns `GroupPreview` (`offered` + `PreviewVenue`s and `skipped`) without a lease, a journal write or a venue RFQ call. Matching + answers are kept 60 s (`MATCH_TTL_S`), shared with creates. `POST /trading/rfq-groups/preview` + adds each offered venue's leg prices from the server's catalogue and their product (cached 10 s). +- A Kalshi collection's `size_max` of 0 (or none) is no maximum; it was read as a maximum of 0, + which refused every Kalshi combo in such a collection. +- `GET /trading/combos/board` (`synpath/engine/combo_board.py`): a combo venue's eligible markets + as games and questions with categories ([server](server.md#the-combo-board)); Kalshi's needs the + read adapter's new `fetch_events_by_ids` (one batched `/events?tickers=` read). Serve's trading + app now holds the read registry (`app.state.registry`) for both. +- **From the venues' own fields (2026-10-22, checked on live data).** Team codes and labels no + longer come from URL slugs, nor types from titles ([server](server.md#the-combo-board)): + - Polymarket: the catalogue row has no type, team or line (`id`, `slug`, `title`, `outcomes`, + `outcome_prices`, `tags`, `volume` only), so the board reads Gamma's market + (`sportsMarketType`: `moneyline`, `spreads`, `totals`, and many others such as + `soccer_exact_score`, `child_moneyline`, `second_half_spreads`; `line`, signed for the first + outcome; `groupItemTitle`, which names a soccer result's team or `Draw (...)`) and the game's + Gamma event (`teams`: `name`, `abbreviation` in lower case, `alias`, `ordering`). The read + adapter's new `fetch_events_by_ids` (Gamma `/events?id=`, 20 a call) gives the teams. A gamma + market has no team fields; outcomes are matched to the event's teams by name or alias. + - Kalshi: the event's `product_metadata` (`competition`, `competition_scope`: `Game`, `Spread`, + `Point Total`, `1st Half Winner`, `Team Total`, `BTTS`...) and `sub_title` (`NE vs CHI (Oct + 22)`: the codes); the market's `yes_sub_title` (`Chicago`, `Tie`, `Chicago wins by over 16.5 + points`, `Over 27.5 points`), `custom_strike` (`{"football_team": }`, the same id on the + team's winner and spread markets) and `floor_strike` (the line). Kalshi has no team + abbreviation field other than the `sub_title`, and no game start time on the event (the board + keeps the market's close). + - Polymarket US: `marketType` (`moneyline`, `drawable_outcome`; every combo-enabled market read + was one of these: no spreads or totals were combo-enabled), `sportsMarketTypeV2`, + `marketSides[].team` (`name`, `displayAbbreviation`, `alias`) and the event's `teams`; there is + no `line` field on the markets read. +- Tests: `tests/test_combo_board.py` (19) on `tests/samples/polymarket_combo_board_live.json`, + `polymarket_us_combo_board_live.json` and `kalshi_combo_board_live.json` (trimmed live reads: + 4 Polymarket games, 7 Polymarket US games, 5 Kalshi games and Kalshi's other scopes), the readers + and Gamma's events by id; `TestPreview` in `tests/test_engine_rfq_group.py` and + `tests/test_server_rfq_group.py`, `TestEventsByIds` in `tests/test_kalshi.py`. + +### Warm boards and skip reasons (2026-10-22) + +- **Skip reasons name every leg.** Yuki's Polymarket combo "Will Al Fayha Saudi Club win on + 2026-10-22?" (`polymarket:5441701`: hosted matching lists it on Polymarket US as + `polymarket_us:atc-spl-haz-fay-2026-10-22-fay`, not on Kalshi) and "Will the U.S. invade Iran + before 2027?" (`polymarket:665374`: matched nowhere) was skipped correctly on both venues, but a + skip named only its first leg, by id, and the terminal showed the leg's outcome (`no "YES"`). Now + `_plan_combo` maps every leg before deciding, eligibility names every leg it refuses, and + `SkippedVenue.missing_legs` carries each with a label: Kalshi "no match for “Al Fayha to win”, + “U.S. invades Iran”", Polymarket US "no match for “U.S. invades Iran”". A Kalshi leg whose event + is in no open collection is its own code, `outside_collections` (it was `ineligible_leg`). +- **The board is warm** (`synpath/server/combo_warmer.py`, [server](server.md#the-combo-board)): + built at serve's start and every minute after, through the venue's whole list (the board read on + request stopped at 5 pages: 5,000 rows, `complete: false`; the whole catalogue is about 214,000 + rows, so a first build serves its board so far as it goes, and Gamma's reads are kept between + builds); requests answer from it at once + (`status: "building"` until the first build ends) and read it in parts: + `GET /trading/combos/board/categories` and `GET /trading/combos/board?category=&cursor=&limit=`. +- Tests: `tests/test_combo_warmer.py` (the warmer, the routes, the skip reasons on both real legs + with Gamma's real rows in `tests/samples/polymarket_combo_skip_legs_live.json` and matching + mocked from its real answers, and serve's wiring). + +### Over HTTP + +`POST /trading/rfq-groups`, `POST /trading/rfq-groups/preview`, `GET /trading/combos/board`, +`GET /trading/combos/board/categories`, `GET /trading/rfq-groups`, +`GET /trading/rfq-groups/{id}`, `POST /trading/rfq-groups/{id}/accept`, +`DELETE /trading/rfq-groups/{id}` ([server](server.md#cross-venue-rfq-groups)). + +### Tests and what is unverified + +`tests/test_engine_rfq_group.py` (25: accept by venue with colliding quote +ids, expiry) and `tests/test_server_rfq_group.py` (16: `400 venue_required`): paper RFQ venues under the names `kalshi` (collections, quadratic 7 % +fee), `polymarket` (eligible list, no fee, no requester cancel), +`polymarket_us` (eligible list, 2c a contract) and `limitless`, with the +hosted matching service mocked under the real `match_market` +(`httpx.MockTransport`). + +UNVERIFIED against a venue: combo fee estimates (`fetch_fee_estimate` on a +combo market id; unknown counts as zero); Polymarket global's +`rfq_one_sided` sizing in a group; how fast each venue's combo-eligible list +finds a leg beyond its first pages; whether the matching service's +`side_map` holds for every combo leg; a Kalshi collection choice when +several fit (the group takes the first, sorted). diff --git a/docs/server.md b/docs/server.md index b82215b..4ab40cc 100644 --- a/docs/server.md +++ b/docs/server.md @@ -4,31 +4,53 @@ Back to the [README](../README.md). ```bash pip install synpath -synpath serve # your self-hosted server: market data at /, trading at /trading, the engine behind it -python -m synpath.server # market data only: http://127.0.0.1:8000/docs +synpath serve # your self-hosted server: market data at /, matching and history at /matching and /history, + # live streams at /ws/market, trading at /trading +python -m synpath.server # market data only, no authentication: http://127.0.0.1:8000/docs ``` `synpath serve` is the whole self-hosted stack in one process; see [docs/engine.md](engine.md#running-it). The rest of this page is the two -apps it serves, which can also be built and mounted yourself. +apps it serves, which can also be built and mounted yourself, and the live +market socket it adds. + +**Under `synpath serve` every route needs the access token**: the market-data +routes (batch reads and search included), `/matching`, `/history`, +`/ws/market` and everything under `/trading`. Only `/` (what this +server is), `/health` and the API docs (`/docs`, `/openapi.json`, and the +same under `/trading`) stay open: they touch no venue and say nothing about +an account. Send the token as `Authorization: Bearer ` (or +`X-API-Key`); a socket may pass it as `?key=` instead, since a browser cannot +set headers on one. A missing or wrong token is a `401`; a socket is accepted +and then closed with `4401`. The standalone `python -m synpath.server` keeps +no accounts, so it has no token; bind it to localhost or mount it behind your +own auth (below). ```bash -curl "localhost:8000/venues/kalshi/markets?limit=1" -curl "localhost:8000/venues/kalshi/markets/KXELONMARS-99/book?side=no" +TOKEN=... # ~/.synpath/servers.json, or what `synpath serve` printed +curl -H "Authorization: Bearer $TOKEN" "localhost:8000/venues/kalshi/markets?limit=1" +curl -H "Authorization: Bearer $TOKEN" "localhost:8000/venues/kalshi/markets/KXELONMARS-99/book?side=no" ``` | Route | | |---|---| -| `GET /health` | | +| `GET /` | what this server is, with links; open | +| `GET /health` | liveness; open | | `GET /venues` | ids, book models, capabilities | | `GET /venues/{venue}/markets` | `?query= &limit= &cursor= &status= &sort=` (`volume`, `liquidity`, `newest`; anything else is a 400) | | `GET /venues/{venue}/markets/{market_id}` | | | `GET /venues/{venue}/events` | | | `GET /venues/{venue}/markets/{market_id}/book` | `?side=yes|no &depth=` | -| `GET /venues/{venue}/markets/{market_id}/trades` | `?since= &limit= &cursor=` | -| `GET /venues/{venue}/markets/{market_id}/candles` | `?timeframe= &since= &until= &limit= &cursor=`, in the YES price; with `since`, paged forward by `next_cursor` | +| `GET /venues/{venue}/markets/{market_id}/trades` | `?since= &limit= &cursor=`; `limit` up to 1000 | +| `GET /venues/{venue}/markets/{market_id}/candles` | `?timeframe= &since= &until= &limit= &cursor= &source=`, in the YES price; with `since`, paged forward by `next_cursor`; `limit` up to 20,000; `source=quotes` is Polymarket's sampled midpoint history (other venues refuse `source` with 400) | | `GET /venues/{venue}/markets/{market_id}/fee` | | | `GET /venues/{venue}/series/{series_id}` | Kalshi and Polymarket US | +| `GET /markets?ids=` | many markets by Synpath id, across venues; see [batch reads](#batch-reads-and-search) | +| `GET /books?ids= &side= &depth=` | many books by Synpath id, across venues | +| `GET /search/markets?query= &venue= &limit= &cursor= &status=` | one search on several venues | +| `GET /search/events?query= &venue= &limit= &cursor= &status=` | the same, events with markets nested | +| `GET /matching/market?id= &venue=`, `GET /matching/event?id=`, `GET /matching/arbitrage` | `synpath serve` only; Synpath's hosted matching, see [hosted services](#hosted-services-matching-and-tick-history) | +| `GET /history/order-book/at`, `/history/order-book/range`, `/history/trades/range` | `synpath serve` only; Synpath's hosted tick history | Lists are enveloped with the cursor; single resources are not. @@ -40,17 +62,211 @@ Every error, on both apps, is one shape. `code` is the library's exception in snake case, or the HTTP layer's own (`unauthorized`, `forbidden`, `validation_error`, `unknown_venue`); `details` always has `venue` and `retryable`, plus `rule` on a risk refusal, `reason` on a venue refusal, -`retry_after` on a rate limit, and `errors` on a malformed request. +`retry_after` on a rate limit, `client_order_id` on an order whose outcome is +not known yet, and `errors` on a malformed request. ```json { "error": { "code": "rate_limit_exceeded", "message": "...", "details": { "venue": "kalshi", "retryable": true } } } ``` -`400` bad parameters or a venue's refusal of an order, `401` no token, `403` not -permitted, `404` no such thing, `409` a risk rule or a halt, `422` a malformed -body, `429` slow down, `501` the venue has no such capability, `502` the venue -failed, `504` it did not answer. The events socket closes with `4401` without a -valid token. +`202` an order whose outcome is not known yet (below), `400` bad parameters or +a venue's refusal of an order, `401` no token, `403` not permitted, `404` no +such thing, `409` a risk rule, a halt, or a cancel the venue could not confirm, +`422` a malformed body, `429` slow down, `501` the venue has no such +capability, `502` the venue failed, `504` it did not answer. The sockets (`/ws/market`, +`/trading/ws/events`) close with `4401` without a valid token. + +## Batch reads and search + +The HTTP form of `synpath.Client`'s cross-venue reads: `fetch_markets_by_ids`, +`fetch_order_books`, and `fetch_markets` / `fetch_events` with a query and no +venue. Ids are grouped by venue and each venue is asked once, in parallel, +through the same adapters (and rate limiters) as every other route; each +venue batches as the [API table](api.md) says (Polymarket answers 100 markets +or 500 books in one request). + +| Route | Parameters | Response | +|---|---|---| +| `GET /markets` | `ids`: comma-separated Synpath ids, at most 100 (duplicates once) | `{"data": [Market, …], "errors": {id: error}}`; `data` in the order asked | +| `GET /books` | `ids` as above, `side` (`yes`, default, or `no`), `depth` (1–1000) | `{"data": {id: OrderBook}, "errors": {id: error}}` | +| `GET /search/markets` | `query` (1–200 characters), `venue` (repeatable; default every venue), `limit` (per venue, 1–100, default 20), `cursor` (only with exactly one `venue`), `status` | `{"results": {venue: {"data", "next_cursor", "count"}}, "errors": {venue: error}}` | +| `GET /search/events` | the same | the same, with `Event`s | + +`GET /markets` is also the batch **quote** read: each market carries +`yes.quote` and `no.quote` (bid, ask, last, sizes) and `stats`, exactly as +`fetch_markets_by_ids` returns them. + +**Partial results, never a failed call.** Every id asked for is in exactly +one of `data` and `errors`, and every venue searched in one of `results` and +`errors`. Each error is the same `{"code", "message", "details"}` a single +route would answer with: `bad_market_id` (not `venue:native`), +`unknown_venue`, `market_not_found` (the venue no longer serves it), or the +venue's own failure, such as `exchange_not_available` with +`details.retryable: true`. A venue without a batch book endpoint is read one +book per market. Only a request wrong as a whole is refused: more than 100 +ids (`400 too_many_ids`), no ids, a `cursor` with several venues (`400`), an +unknown `venue` in a search (`404 unknown_venue`). + +## Hosted services: matching and tick history + +`synpath serve` only. These call Synpath's hosted services, matching at +`https://api.synpath.dev` (`SYNPATH_MATCHING_URL`) and tick history at +`https://api2.synpath.dev` (`SYNPATH_HISTORY_URL`), with **serve's** Synpath +API key: `SYNPATH_API_KEY` in serve's environment, otherwise the key +`synpath login` and `synpath keys create` saved. A client of serve needs no +Synpath key and never connects to either service itself. + +| Route | Parameters | Response (library call) | +|---|---|---| +| `GET /matching/market` | `id` (Synpath id), `venue` (optional: where to look) | `MarketMatch` (`synpath.match_market`); `matched: null` means nothing on the other venue | +| `GET /matching/event` | `id` | `EventMatch` (`synpath.match_event`) | +| `GET /matching/arbitrage` | | the matching service's cross-venue scan, as it answers: `computed_at` and `results` (each outcome's `yes` and `no` legs with `venue`, `native_market_id`, `title`, `side`, `price`, `fee`, `url`; `gross_spread`, `fees`, `net_profit`) | +| `GET /history/order-book/at` | `market_id`, `as_of_ms`, `side`, `depth` (1–1000) | `OrderBookAtResponse` (`fetch_order_book_at`) | +| `GET /history/order-book/range` | `market_id`, `start_ms`, `end_ms`, `side`, `limit` | `OrderBookRangeResponse` (`fetch_order_book_range`) | +| `GET /history/trades/range` | `market_id`, `start_ms`, `end_ms`, `limit` | `TradesRangeResponse` (`fetch_trades_range`) | + +A hosted failure has its own code, so it is never mistaken for serve +refusing the caller's token (`401`): + +| The hosted service | Serve answers | +|---|---| +| refuses serve's Synpath API key, or there is none (401, 403) | `502 hosted_unauthorized`; the message says to set `SYNPATH_API_KEY` or run `synpath login` | +| has no such listing (its own 404) | `404 market_not_found` | +| is not deployed (a 404 from the host in front of it) | `502 hosted_unavailable`, retryable | +| cannot return the range whole (413) | `413 result_too_large`: narrow the window or raise `limit` | +| rate limits (429) | `429 rate_limit_exceeded`, retryable | +| does not answer in time | `504 hosted_timeout`, retryable | +| is unreachable or fails (5xx) | `502 hosted_unavailable`, retryable | + +Every error carries `details.venue: "synpath"`. An id that is not a Synpath +id, or a window whose `end_ms` is not after `start_ms`, is a `400` before +any hosted call. + +## Live market data: `/ws/market` + +`synpath serve` only. One socket carries any number of markets: books, +public trades and quotes, from the library's own venue streams +([streaming](streaming.md)), on all eight venues. + +```bash +websocat "ws://127.0.0.1:8000/ws/market?key=$TOKEN&market_id=kalshi:KXELONMARS-99&channels=book,trades&depth=16" +``` + +Subscriptions can be named on the URL (`market_id`, repeatable; `channels`, +default `book`; `side`, default `yes`; `depth`, default the whole book), and +added or dropped at any time with messages: + +```json +{"op": "subscribe", "market_id": "polymarket:2252244", "channels": ["book", "trades", "quotes"], "side": "yes", "depth": 16} +{"op": "unsubscribe", "market_id": "polymarket:2252244"} +``` + +Market ids are Synpath ids (`venue:native`), or a bucket's `bucket:` +([below](#a-buckets-merged-book)). The server answers with JSON +objects, each with a `type`: + +| `type` | When | Fields | +|---|---|---| +| `subscribed` | a subscribe took | `venue`, `market_id`, `side`, `channels` (those the venue streams), `unsupported` (asked for, not streamed by this venue) | +| `unsubscribed` | an unsubscribe took | `market_id` | +| `book` | on subscribe, then after every change, at most every `book_interval_s` (config, default 0.1 s) | `venue`, `market_id`, `side`, `ready`, `bids` and `asks` (`[{"price", "size"}]`, best first, decimal strings), `best_bid`, `best_ask`, `sequence`, `timestamp` (venue), `sent_at` | +| `trade` | channel `trades` | `venue`, `market_id`, `id`, `price` (YES), `quantity`, `taker_side` (`buy` took YES), `timestamp`, `received_at` | +| `quote` | channel `quotes`, the subscribed side only | `venue`, `market_id`, `side`, `bid`, `ask`, `bid_size`, `ask_size`, `last`, `volume`, `open_interest`, `timestamp`, `received_at` | +| `market_status` | the venue reports a market opening, pausing, closing or resolving | `venue`, `market_id`, `state`, `native`, `result`, `timestamp` | +| `status` | the venue connection itself: `connected`, `disconnected`, `gap`, `resynced`, `error`, `failed`, … | `venue`, `state`, `detail` | +| `error` | a request refused, or the stream lost | `code`, `message`, `market_id` | + +**A book is the stream's maintained book, never re-derived.** Every `book` +message is the market's whole book (to `depth`) as the venue stream keeps +it, so a client needs no delta logic. `ready: false` (with no levels) means +the stream has no snapshot yet, or stopped trusting the one it had after a +drop or a gap; the venue stream reconnects by itself, and the fresh snapshot +is sent as soon as it lands. A book changing faster than `book_interval_s` +is coalesced: the client always gets the latest state, not every step. + +**One venue connection, shared.** The server opens one market connection per +venue and reference-counts markets on it across every socket. A venue the +engine trades is read from the engine's own feed connection, so the screen +and the stops see the same book; the server never opens a second one. A stop +placed on a market a screen already shows reads that book at once: the venue +sends no new snapshot for it, and the stop does not wait for the next change. When +the last socket leaves a market it is unwatched upstream (Kalshi and +Polymarket US streams cannot unwatch, so their subscription stays until the +connection closes) unless the engine still watches it; a connection the +server opened for sockets alone closes with its last subscriber. + +**Credentials come from the server.** Polymarket, Hyperliquid and Limitless +stream without any. Kalshi, Polymarket US, Opinion, predict.fun and SX Bet +need their keys in the server's environment or `.env`; without them a +subscribe answers `error` with `credentials_required` rather than falling +back to polling. Other codes: `bad_market_id`, `bad_request`, +`not_subscribed`, `subscribe_failed` (the venue refused the market), +`streams_disabled` (`--no-streams`), `stream_ended` (the venue refused the +connection for good; subscribe again to reopen it). Not every venue streams +every channel: Limitless and predict.fun carry no public trades, and that is +said in `unsupported`. A client that falls more than 10,000 messages behind +is closed with `1013`. + +`GET /market/streams` says what each open venue connection is doing: +`connected`, `connecting`, `reconnecting` (the stream retries by itself) or +`failed` (refused for good), with the last thing it reported. A venue not +listed has no connection open. The engine's own connections are always +listed; serve subscribes the trades of every market a stop watches or a +position holds, beside its book. + +### A bucket's merged book + +A bucket (`bucket:`, defined through `/trading/buckets`) subscribes like +a market, on the same socket: + +```json +{"op": "subscribe", "market_id": "bucket:3f2a…", "channels": ["book", "trades"], "depth": 16} +``` + +The server subscribes every enabled member on the shared venue connections +above (a bucket opens none of its own) and merges their books with the library's +router (`synpath.engine.router.view` and `merge`): each member's book in +bucket terms, a flipped member read through its NO side, every level net of +its venue's taker fee, labelled with the member it came from. `side` must be +`yes` (the book is already in bucket terms); `depth` is levels per member and +side; `quotes` is not streamed for a bucket. The key must see every member +venue, as on the bucket routes. + +| `type` | When | Fields | +|---|---|---| +| `subscribed` | the subscribe took | as for a market, with `venue: "bucket"` and `bucket` (the definition) | +| `bucket_book` | on subscribe, then after any member's book changes (coalesced as `book` is) | `market_id`, `bucket_id`, `name`, `side`, `ready`, `asks` (cheapest net first) and `bids` (richest net first), each `[{"price", "net_price", "size", "market_id", "venue"}]`; `best_ask`, `best_bid` (best price before fees), `best_ask_net`, `best_bid_net`; `members` (`market_id`, `venue`, `flip`, `enabled`, `ready`, `sequence`, `timestamp`, `error`; a member switched off is listed with `enabled: false`, `ready: false` and no book); `fees_unknown` (members whose fee the library cannot price, counted as free); `fee_floors` (per-order minimum fee by member); `timestamp`; `sent_at` | +| `trade` | channel `trades`: a member's public trade | as for a market, with `market_id` the bucket's, `member_market_id`, `flip`, and `price` and `taker_side` in bucket terms (a flipped member's YES trade at p is 1 - p, the taker on the other side) | +| `status`, `market_status` | as for a market | the member venue's own | +| `error` | a member the server cannot stream (`credentials_required`, …, with `market_id` the bucket's), or `bucket_not_found`, `forbidden`, `buckets_unavailable`, `bad_request` | `code`, `message`, `market_id` | + +**Ready only while every enabled member is.** A member switched off +(`enabled: false` on the definition) is not subscribed, adds no levels and +does not hold `ready` back. A bucket missing an enabled member is not the +bucket, so while any enabled member's book has no snapshot, is untrusted after a drop +or a gap, or is crossed, `bucket_book` comes with `ready: false` and no levels; +`members` says which. Fees are each member's published fee schedule +(`fetch_fee_schedule`), read once and kept ten minutes, quoted for 100 +contracts and divided back to one, as an order on the bucket prices its levels. +Editing a bucket (`PATCH`, including switching a member on or off, or archive) +moves every socket streaming it to the new definition: a member switched on is +subscribed, one switched off released. A member's stream ending (`stream_ended`) ends the bucket +subscription too; subscribe again. + +### Previewing an order on a bucket + +`POST /trading/buckets/{id}/plan` with `{"side": "buy", "quantity": "100", +"worst_price": "0.45"}` answers the router's `plan()` for a market order on +the bucket, now, over its enabled members only: `legs` (per member: `market_id`, `venue`, `flip`, the +member's own `side` and YES `price` after the flip and tick rounding, as the +venue would receive them; `quantity`, `bucket_price`, `net_price`, `fee`, +`fee_floor`), `allocated` (never more than `quantity`), `unfilled`, +`stop_reason` (`worst_price`, `liquidity`, `min_quantity`, or null when it +fills), `expected_net_price`, `fees`, `fees_unknown`, `computed_at`, and +`books`: per enabled member, `stream` when the server holds its live book, else `rest` +(read from the venue for this request) or `missing`. Fees and precision are +the ones an order on the bucket uses. Nothing is placed or reserved; the +books can move before an order arrives. ### Typed clients in any language @@ -92,16 +308,64 @@ python -m synpath.server bootstrap --control control.db # the same, from a she |---|---|---| | `GET /me`, `GET /accounts`, `GET /status` | any key / `view` | who this key is, what it may touch, what the engine is doing | | `POST /orders`, `PATCH /orders/{id}`, `DELETE /orders/{id}` | `trade` on that account | place, amend, cancel; engine-held types too | -| `POST /buckets`, `DELETE /buckets/{id}` | `trade` on every member venue | define a bucket (the server assigns its id); archive one | -| `GET /buckets`, `GET /buckets/{id}`, `GET /buckets/{id}/orders`, `GET /buckets/{id}/orders/{order_id}`, `GET /buckets/{id}/position` | `view` on every member venue | buckets, the orders on one with fills by venue, the position netted in bucket terms | +| `POST /buckets`, `PATCH /buckets/{id}`, `DELETE /buckets/{id}` | `trade` on every member venue | define a bucket (the server assigns its id, or keeps a client-made UUID `id`; taken is `409`); rename it, move it to another `book`, replace its `members` (each `market_id`, `flip`, and `enabled`, default `true`: a member switched off stays in the definition but is not routed to or merged; at least two members, at least one enabled), or archive or restore it (`status`); archive one | +| `GET /buckets` (`book`, `status`), `GET /buckets/{id}`, `GET /buckets/{id}/orders`, `GET /buckets/{id}/orders/{order_id}`, `GET /buckets/{id}/position` | `view` on every member venue | buckets, the orders on one with fills by venue, the position netted in bucket terms | +| `POST /buckets/{id}/plan` | `view` on every member venue | preview where an order on the bucket would go now (below); nothing is placed | | `GET /orders`, `GET /orders/{id}`, `GET /fills`, `GET /positions`, `GET /balances`, `GET /pnl` | `view` | what the engine and the venues hold | -| `GET`/`PUT /fair-values` | `view` / `trade` | the marks positions are valued at | +| `GET /order-log` (`order_id`, `limit`, `before`) | `view` | every action on orders from the journal, newest first, repeats folded (`synpath.engine.order_log`): saved, sent, accepted, triggered, cancel requested, cancelled, filled, refused; one order with its cancel, fills and children | +| `GET /journal` (`kind`, `limit`, `before`) | `view` | the journal's events as recorded, newest first, every kind or one prefix | | `GET /risk`, `PUT /risk` | `view` / `manage_credentials` | the rules in force, versioned on change | -| `POST /halt`, `POST /resume` | `trade` | the kill switch | +| `POST /halt`, `POST /resume` | `trade` | the kill switch; with the `cancel` policy it first cancels our open RFQs and quotes (`rfq` in the result) | +| `POST /rfqs`, `DELETE /rfqs/{id}` | `trade` on the venue's account | ask for quotes on a market or a combo, by `quantity` or `amount`; cancel one ([RFQs](#rfqs-quotes-and-combos)) | +| `GET /rfqs` (`venue`, `status`, `market_id`, `source`, `limit`), `GET /rfqs/{id}`, `GET /rfqs/{id}/quotes` (`status`) | `view` | our RFQs, and the quotes on one | +| `POST /quotes/{id}/accept` | `trade` | take one side of a quote: `202` at once, `200` when `wait` and the quote is final | +| `GET /rfqs/incoming`, `GET /quotes` (`venue`, `rfq_id`, `status`) | `view` | the quoter side: others' open RFQs, our quotes | +| `POST /quotes`, `PUT /quotes/{id}`, `DELETE /quotes/{id}`, `POST /quotes/{id}/confirm` | `trade` | send, replace, withdraw a quote; confirm an accepted one (`202`/`200` as accept) | +| `POST /combos`, `POST /combos/lookup`, `GET /combos/{market_id}` | `trade` / `view` / `view` | create or look up the combo for some legs (idempotent; `collection_id` on Kalshi); look one up without creating it; read its legs | +| `GET /combos/markets` (`venue`, `event_id`, `tag`, `limit`, `cursor`), `GET /combos/collections` (`venue`, `status`, `event_id`, `limit`, `cursor`) | `view` | where combos can be built: Polymarket's and Polymarket US's combo-eligible markets; Kalshi's collections with their event tickers. Cached 15 s | +| `GET /combos/board/categories` (`venue`) | `view` | a combo board's rail: its categories with counts, `status` (`building` / `ready`), `built_at`, `complete` ([combo board](#the-combo-board)) | +| `GET /combos/board` (`venue`; `category`, `cursor`, `limit`, `q`, `sort`) | `view` | a combo venue's eligible markets as games (moneyline / spread / total picks per participant) and YES / NO questions, with categories and counts: the whole board, or one category a page at a time ([combo board](#the-combo-board)). serve keeps it warm; never waits on a build | | `GET`/`POST`/`DELETE /grants`, `POST /keys`, `DELETE /keys/{id}` | `manage_members` / `manage_credentials` | who may do what | | `GET /audit` | `manage_members` | the append-only history of those changes | | `WS /ws/events` | `view` | the engine's events, replayed from a cursor then live | +**Listing orders.** `GET /orders` takes `scope` (`open`, the default: resting +venue orders and live engine-held parents, `waiting`, `triggered` or +`pending_cancel`; `all`: every state, `closed`, `canceled`, `rejected` and +`expired` too), `market_id`, `venue`, `book`, `since` (created at or after, +milliseconds since the epoch), `limit` (1–1000; default every open order for +`open`, 100 for `all`) and `cursor`. Rows are `Order`s, newest first by +creation. An engine-held parent has `held_by: "engine"`; a venue order a +parent placed has `held_by: "venue"` and the parent's id in `parent_id`, so +children group under their parent. Follow `next_cursor` until it is null; a +page can hold fewer than `limit` rows when some are on accounts the key may +not view. A cursor this server did not give out is `400`. + +**An order whose outcome is not known yet is `202`, not a failure.** When the +venue gives no verdict (a timeout, a lost response, a 5xx), the send may have +landed, so `POST /orders` answers `202` with the error envelope, code +`submission_in_doubt`, `details.retryable: true` and `details.client_order_id` +(the one sent, or the one the engine assigned). Retry with the same body and +`client_order_id`: the engine asks the venue first and answers `201` with the +order it finds, `202 submission_pending` while the venue has no answer yet, or +the original refusal; it never sends a second order. Reusing the id for a +different instruction is `400`. `synpath.Client(server=...)` raises +`SubmissionInDoubt` and `SubmissionPending`, each with `client_order_id`. + +```json +{ "error": { "code": "submission_in_doubt", "message": "...", + "details": { "venue": "kalshi", "retryable": true, "client_order_id": "6f1c…" } } } +``` + +**A cancel never relabels a fill.** `DELETE /orders/{id}` on an order already +finished returns it as it is. When the venue answers "not found", the server +reads the order back and returns the venue's verdict: `closed` with its +filled quantity if it filled first, `canceled` only if the venue cancelled +it. An engine-held parent may answer `pending_cancel` until its child's fate +is known. A venue with no record of the order at all is `409 +cancel_unconfirmed` (`CancelUnconfirmed` in Python); the order keeps its last +observed status. + **Permissions are per subaccount and nothing is implied.** A grant is `(user, account, permission)` where the account is `venue:name` or `*`, and the permission is one of `view`, `trade`, `manage_credentials`, @@ -124,11 +388,257 @@ reconnects does not miss the fill that happened while it was away. websocat "ws://127.0.0.1:8000/trading/ws/events?key=$SYNPATH_ACCESS_TOKEN&since=0&kinds=order,fill" ``` +### RFQs, quotes and combos + +The engine's RFQ desk ([docs/rfq-combo.md](rfq-combo.md)) over HTTP. Every +write goes through the engine, so risk, the journal, the halt and recovery +are the same as for an order. Prices are YES prices; a quote's `ask` is what +we can buy YES at, its `bid` what we can sell at. + +**Which venue.** RFQ and quote ids are the venue's own, not Synpath ids. A +route takes the venue from `venue` (query or body), the `account` named, a +Synpath market id, what the engine already holds about the id, or the only +venue with the capability; otherwise it is `400`. The grant is checked on +that venue's account (`venue:name`) before anything is sent. A venue without +the capability (`has["rfq_taker"]`, `rfq_quoter`, `combos`) is `400`; a call +the venue does not offer (Polymarket US has no read-only combo lookup) is +`501 not_supported`; an unknown id is `404`. + +**Accept and confirm answer `202`.** `POST /quotes/{id}/accept` takes +`AcceptQuote` (`rfq_id`, `outcome`, `action`, `worst_price`, `quantity` or +`amount`, `client_order_id`, `account`, `book`, `trader`, `tags`) plus `wait` +and `timeout_s` (default 30, at most 120). It answers `QuoteResult`: the +`quote`, the intent key `client_order_id`, `final`, `intent_state` (`sending` +until the quote is final, then `sent` or `failed`) and `order_id` once the +execution's order is adopted. The status is `202` unless `wait` was true and +the quote ended (`executed`, `canceled`, `expired`, `failed`), then `200`. +The rest arrives as events. A send with no answer is `202 +submission_in_doubt` with `details.client_order_id`; a retry under that key +reads the venue first and never sends twice. `POST /quotes/{id}/confirm` +(the maker's last look; body optional: `rfq_id`, `venue`, `wait`, +`timeout_s`) is the same, keyed `rfq-confirm:{venue}:{quote_id}`. + +**Refusals.** A risk rule is `409 risk_rejected` with `details.rule`: +`worst_price` (the quote is worse than the caller's limit), `kill_switch`, +`max_order_contracts`, `max_order_notional`, `max_position`, and the other +order rules. Accept and confirm run every order rule; creating an RFQ and +sending a quote run the halt, a paused book, the restricted list and the +size limits. Cancels (`DELETE /rfqs/{id}`, `DELETE /quotes/{id}`) are never +refused by risk. + +**Lists.** `GET /rfqs` and `GET /rfqs/incoming` read each venue by default; +`source=engine` returns what the engine already holds (our open RFQs, the +incoming feed's last view) without a venue call. Rows are filtered to the +accounts the key may view. `GET /rfqs` rows carry the engine's `book` and +`trader`; `GET /quotes` fills in the `expires_at` the engine holds (venues +may not take one). + +**Combos.** `POST /combos {legs: [{market_id, outcome}], venue?, collection_id?}` +creates or returns the combo (the same legs give the same id; an existing +combo costs no venue quota) and keeps its legs with the engine +(`combo.saved`). A combo is its own instrument: `POST /rfqs` with its +`market_id`, and its position carries `info.combo` with the legs. +`collection_id` is Kalshi's: the collection to build in. Without it the one +open collection that takes every leg's event is used; when several do, the +answer is `400` naming them, with the tickers in `details.collections`. +Polymarket and Polymarket US refuse it (`400`). + +**Where combos can be built.** Two reads, each served from memory for 15 s +(per server, by query) so the terminal can poll them: + +- `GET /combos/markets?venue=polymarket|polymarket_us` pages the venue's own + list of combo-eligible markets as `ComboMarket` rows: + `{market_id, venue, title, event_id, event_title, position_ids, min_legs, + max_legs, status, tags, info}` in `{data, next_cursor, count}`. + `market_id` is the leg's. Polymarket (the public catalogue, by volume): + `position_ids` `{yes, no}` name the leg's `info.position_id`; `tag` is + applied to each page, `event_id` is a `400`. Polymarket US (open events' + `comboEnabled` markets, newest first): `event_id` + (`polymarket_us:`) and `tag` (a tag slug) are the venue's + filters, and `limit` counts events read. Either way a page can be short + or empty and still carry a `next_cursor`. `501` elsewhere. +- `GET /combos/collections?venue=kalshi` pages Kalshi's collections as the + venue's objects; each carries `associated_event_tickers` (every event a + leg may come from), `size_min`/`size_max` and `associated_events`. + +**Events.** `/ws/events` carries `rfq.created`, `rfq.closed`, `rfq.in_doubt`, +`rfq.side_mismatch`, `quote.received`, `quote.sent`, `quote.replaced`, +`quote.accepted`, `quote.confirmed`, `quote.executed`, `quote.canceled`, +`quote.expired`, `quote.failed`, `quote.in_doubt`, `combo.saved`, then the +resulting order's `order.*` and `fill.booked`. Quote events are keyed +`{venue}:quote:{id}`. `rfq.incoming` is not journaled, so it is live only: a +replay from a cursor does not include it. `kinds=rfq,quote,combo` filters +the replay and the live stream alike. + +### Cross-venue RFQ groups + +One RFQ on every venue that lists the same market or combo, ranked as one +list; accepting one quote cancels the rest +([design](rfq-combo.md#10-cross-venue-rfq)). + +| Route | Grant | Answer | +|---|---|---| +| `POST /rfq-groups` | `trade` on each venue asked | `201` `RfqGroup` | +| `POST /rfq-groups/preview` | none beyond the key (venues it may not trade are `forbidden`) | `200` `GroupPreview`: a dry run, nothing created | +| `GET /rfq-groups?status=&limit=` | `view` on every venue of a group | `{data, next_cursor, count}` of `RfqGroup` | +| `GET /rfq-groups/{id}?refresh=` | `view` on every venue of it | `RfqGroup`; `refresh=true` reads each open member's quotes first | +| `POST /rfq-groups/{id}/accept` | `trade` on every venue of it (the others are cancelled) | `202` `GroupAcceptResult`, `200` with `wait` once final | +| `DELETE /rfq-groups/{id}` | `trade` on every venue of it | `RfqGroup`, `canceled` | + +- **Create body**: `instrument_id` (a market, `bucket:`, or a combo's + own id) or `legs` (`[{market_id, outcome}]`, each on any venue or a + bucket), exactly one; `quantity` or `amount`, exactly one; `outcome` + (`yes`) and `action` (`buy`), in the instrument's terms; `cross_venue` + (a plain market: also ask every other RFQ venue's match); `venues` (only + these) and `exclude_venues`; `book`, `trader`, `tags`. A venue the key may + not trade is skipped as `forbidden` (`403` when named in `venues`, or when + the key may trade none). No venue left is `400 no_venue` with + `details.skipped`. +- **`RfqGroup`**: `id`, `instrument`, the size, `outcome`, `action`, + `members` (one per venue: `venue`, `instrument_id`, `flip`, `via`, mapped + `legs`, `combo`, `collection_id`, `rfq_id`, `status`, `detail`), + `skipped` (`venue`, `code`, `reason`, `leg`, and for a combo `missing_legs`: + every leg the venue lacks, each `market_id`, `label`, `code`, `mapped_id`, + `detail`; [skip reasons](#combo-skip-reasons)), `quotes` best first + (`quote_id`, `venue`, `rfq_id`, `raw_bid`/`raw_ask`, `raw_price` (the + member's YES price), `price` (the group's), `outcome_price`, `quantity`, + `fee`, `fee_per_contract`, `effective_price`, `effective_outcome_price`, + `partial`, `rank`, and the venue's deadlines: `expires_at` (the quote's + own expiry, else its RFQ's; null where the venue gives none, as Kalshi and + Polymarket US do), `expires_from` (`quote`, `rfq`), `confirm_by`, + `execute_by`), `expires_at` (the soonest expiry among the quotes that can + be accepted now), `status`, `claim_key`, `accepted_quote_id`, + `accepted_venue`, `order_id`, `failures`, timestamps. A group has no + window of its own: it is `expired` when every member RFQ has closed or + expired on its venue (the follower re-reads each open member RFQ every + 10 s, and at once past its `expires_at`), or `canceled` by `DELETE`. +- **Accept body**: `quote_id`, `venue` (the quote's: quote ids are the + venues' own, so two venues may return the same one; required on a group + that spans more than one venue, else `400 venue_required` with + `details.venues`; a venue outside the group is `400 venue_not_in_group`), + `worst_price` (the group's YES terms), `wait`, `timeout_s`, `tags`. Checked like `POST /quotes/{id}/accept` before + anything is written; a refusal leaves every venue standing. Then the + group is claimed in the journal, every other member RFQ cancelled + (Polymarket global's left to expire: member `lapsing`), and the quote + accepted. `GroupAcceptResult`: `group`, `quote`, `venue`, + `client_order_id` (the member accept's intent), `claim_key`, `final`, + `intent_state`, `order_id`. A second accept on the group is `409` with + `details.rule` `rfq_group_accept_in_flight` or `rfq_group_accepted`; a + failed accept frees the group for another quote. `DELETE` while an + accept is in flight is `409` too. A quote on a member RFQ accepted through + `POST /quotes/{id}/accept` is `400`. +- **Preview body**: the create body with the size optional (unsized, a buy by amount is planned). + `GroupPreview`: `instrument`, `offered` (one `PreviewVenue` per venue the group would ask: `venue`, + `via`, `instrument_id`, `flip`, mapped `legs`, `collection_id`, `eligibility`, and from the + server's catalogue `leg_prices` (each leg's price in its outcome's terms: YES ask, or 1 - YES bid) + and `combined_price` (their product, an estimate, not a quote)), and `skipped` as on a group. The + matching service and the venues' eligibility lists are read; nothing is journaled or sent to an RFQ + desk. Served from memory for 10 s, and matching answers are reused for 60 s, so the group that + follows a preview asks the matching service nothing new. +- **Events** on `/ws/events` (`kinds=rfq_group`): `rfq_group.created`, + `rfq_group.quote` (one ranked `GroupQuote`, with `group_id`), + `rfq_group.updated`, `rfq_group.accepting`, `rfq_group.accepted`, + `rfq_group.executed`, `rfq_group.accept_failed`, `rfq_group.canceled`, + keyed `rfq_group:{id}`; all but `rfq_group.quote` carry the whole group in + `payload.group`. The members' own `rfq.*` and `quote.*` events flow as + before. +- **Halt**: `POST /halt` with the `cancel` policy cancels every open group's + members first (`rfq.groups` in the result). + +### Combo skip reasons + +A combo venue that would not be asked (`skipped` on a preview or a group) names **every** leg it +lacks, not the first: `missing_legs`, each with `market_id` (the leg as given), `label` (a short +readable name for its market, never its outcome), `code`, `mapped_id` (the venue's matched market, +where there is one) and `detail` (what matching or the venue said). The venue's own `code` is the +first of its legs' codes in this order, and `reason` says them all, one clause per code: + +| `code` | Meaning | `reason` | +|---|---|---| +| `missing_leg` | hosted matching lists no such market on the venue | `no match for “Al Fayha to win”, “U.S. invades Iran”` | +| `matching_error` | the matching service could not be asked | `matching unavailable for “Al Fayha to win”` | +| `ineligible_leg` | matched, but not on the venue's combo-eligible list | `not combo-eligible on Polymarket US: “U.S. invades Iran”` | +| `outside_collections` | matched, but its event is in none of the venue's open combo collections, or no one collection takes the legs together (Kalshi) | `Kalshi combos are sports and cross-category only: “U.S. invades Iran” is in none` | + +A label is the leg's pick on serve's warm board (a moneyline by its participant's short name and +`to win`, a spread by its line, a total by its game and side), else its market's question from the +catalogue, shortened (`Will the U.S. invade Iran before 2027?` -> `U.S. invades Iran`), else its +`info.label`, else its market id. Matching is checked for every leg before eligibility, so a venue +that lacks a match for some legs reports those, and eligibility is reported when every leg matched. + +### The combo board + +`GET /trading/combos/board?venue=` turns a combo venue's eligibility into what a combo builder draws +(`synpath/engine/combo_board.py`): `ComboBoard` `{venue, count, complete, as_of, status, built_at, +error, categories, events}`, and on a page `category`, `total` and `next_cursor`. + +- **Kept warm** (`synpath/server/combo_warmer.py`). serve builds every combo venue's board at + start-up, through the venue's whole list (no page cap: Polymarket's catalogue is about 214,000 + rows in 214 pages, with their Gamma markets and events; every event of Kalshi's open + collections), sleeping `pause_s` between two venue reads, and builds it again + `refresh_interval_s` after the last build ended + (`[combo_board]` in the configuration, [engine](engine.md)). A request never waits on a build: + until the first build ends the board is `status: "building"` with no events, answered at once; + after that it is the last good board (`status: "ready"`, `built_at`), kept while a refresh runs + or fails (`error` says why). A first build serves its board so far (`complete: false`) after its + first 5 pages (the venue lists by volume) and every 40 after. Gamma's markets (without their + prices) and the games' events are kept between builds, so a refresh reads Gamma only for markets + it has not seen and takes the catalogue's prices. A bare `create_trading_app` (no warmer) or `warm = false` builds on + request instead, 5 pages, kept 30 s. +- **Beside trading.** A whole-catalogue build is minutes of CPU work in serve's own process, so + it is kept off the event loop that answers health checks and order routes: its steps run in + worker threads that hand back the GIL every 100 items, the GIL switch interval is 0.5 ms while + boards are warmed, and the board's objects are frozen out of the cyclic collector (`gc.freeze`) + as they are built. Measured on the whole Polymarket catalogue: requests stay under 0.8 s during + a build (before: 1-6 s, enough for a terminal to report the engine offline). +- **In parts.** `GET /trading/combos/board/categories?venue=` is the rail: `categories` with counts, + `count` (markets read), `events`, `status`, `built_at`, `complete`, a few hundred bytes. `GET + /trading/combos/board?venue=&category=&cursor=&limit=&q=&sort=` is one page of one category + (`all`: every one): `limit` events (default 50, at most 500) from `cursor` (the last page's + `next_cursor`), those whose title, category or participants hold every word of `q`, in board + order (`sort=board`, by start) or by `volume` or `start` within each category; `total` counts the + category's matching events, and `categories` are the whole board's. Without any of these the + answer is the whole board, as before. + +- **Events**: a `game` (`rows`: one per participant, `name`, `abbr` and `picks` by `moneyline`, + `spread`, `total`, each a `BoardPick` or null where the venue lists no such market) or a + `question` (`yes`, `no`). A `BoardPick` is a leg: `market_id`, `outcome`, `label` (`CHI`, + `CHI -2.5`, `O 41.5`), `kind`, `line`, `title`, and the catalogue's `yes_bid`, `yes_ask` and + `price` (what the pick costs now). Each event has `category` / `category_label` (a league or a + kind, from tags or the Kalshi series), `group` (`sports`, `other`), `start_time`, `volume` and + `market_ids`. +- Everything comes from the venue's own fields, never a URL slug or title wording; a participant + the venue gives no abbreviation shows its name. +- **Kalshi**: the events of its open collections (`associated_event_tickers`), their markets read + from the server's catalogue (`fetch_events_by_ids`, in batches of 100; every event when warm, at + most 150 on request). The type is the event's + `product_metadata.competition_scope` (`Game`, `Spread`, `Point Total`; any other scope, such as + `1st Half Winner` or `Team Total`, is a question); a game joins the events that share the + `sub_title` (`NE vs CHI (Oct 22)`) within one `competition`. Participants are the winner markets' + `yes_sub_title` (`Tie` is the draw), identified by `custom_strike` (the team's id, which a spread + market names too); codes are the `sub_title`'s, side by side with the title's names; a spread's + line is its `floor_strike` (`CHI -2.5` for "Chicago wins by over 2.5"), a total's too. +- **Polymarket**: its catalogue rows (every page when warm; `pages` on request), with Gamma's market for each (from the + server's catalogue: `sportsMarketType` `moneyline` / `spreads` / `totals`, `line`, + `groupItemTitle`, its event) and the games' Gamma events (`fetch_events_by_ids`: `teams` with + `abbreviation`). A two-sided market backs its outcomes' teams (YES the first; a spread at `line` + for the first, the opposite for the second); a YES / NO market (a soccer result) backs its + `groupItemTitle`'s team, or the draw. Without Gamma's market a row is a question. +- **Polymarket US**: its eligible list (events with markets nested): the type from `marketType` + (`moneyline`, `drawable_outcome`, ...), each outcome's team from `marketSides[].team` (`name`, + `displayAbbreviation`), the event's `teams` in order; a drawable result is one YES market per team + and one for the draw. +- Catalogue quotes replace the listed prices. Anything that is not a game is a question (an event + shows its three markets nearest even); a question with named outcomes says them on its buttons. +- `categories`: `key`, `label`, `group`, `count` (events), sports first, by count. + ### Mounting it -The *read* app ships **no authentication**, and none is planned. A service with auth +The *read* app ships **no authentication** of its own, and none is planned. A service with auth baked into its open-source core forces every host to work around it. Put your -own middleware in front: +own in front, either as route dependencies (`create_app(dependencies=[Depends(my_auth)])` +protects every route but `/` and `/health`; this is how `synpath serve` requires its +token) or around the router: ```python from fastapi import Depends, FastAPI diff --git a/docs/streaming.md b/docs/streaming.md index 7a577f6..65d2167 100644 --- a/docs/streaming.md +++ b/docs/streaming.md @@ -22,6 +22,42 @@ async def main(): asyncio.run(main()) ``` +## Stream lifecycle and adapter hooks + +`start()` schedules one background receiver and returns the stream immediately. +It needs a running asyncio loop. It does not wait for an open connection, +completed subscriptions or a ready book. `async with stream` starts receiving +on entry and closes on exit; `async for event in stream` starts receiving if +needed and removes events from the stream's single queue. Multiple readers +share that queue rather than each receiving a copy. Closing ends iteration +when the queued shutdown marker reaches the reader. + +For tests or a custom transport, pass +`websocket_connection_opener=async_opener`. The opener is called on every +connection attempt, including reconnects, with the URL and +`additional_headers=stream.headers()`. It must return a connected socket +supporting the adapter's send, receive and close operations. The default +opener remains unchanged. + +For custom WebSocket adapters, implement `handle_raw(raw: str) -> list[Event]`. +The adapter owns framing, decoding, cached-state updates and any native/Python +selection. Return an empty list for a handled frame with no events; never +return `None`. The shared receiver counts frames and emits the returned +events. gRPC adapters retain their decoded-message `handle()` hook on +`GrpcStream`. + +Migration on the experimental SDK branch: + +- Rename constructor argument `connect` to `websocket_connection_opener`. +- Move a WebSocket subclass's old `handle(message)` logic behind `handle_raw()`; + the base class no longer decodes JSON or chooses a fallback handler. +- Replace calls to the removed `status()` helper with the appropriate private + `_record_*_and_emit_status()` operation. These operations own the related + counters and lifecycle state. `_emit_status_event()` only publishes a status. +- Instrumentation overriding private hooks must follow `_task` → + `_receiver_task`, `_run()` → `_run_connection_and_receive_loop()` and + `_dispatch()` → `_process_raw_frame_and_emit_events()`. + ## Streams | Stream | Endpoint | Credentials | Carries | @@ -112,6 +148,15 @@ parsed and applied there, and the events come out as the same Python objects. `SYNPATH_PURE_PYTHON=1` to run everything on the pure-Python twins instead; the test suite runs both and requires them to agree frame by frame. +## Over HTTP: `synpath serve` + +`synpath serve` publishes these same streams on one authenticated WebSocket, +`/ws/market`: each market's maintained book, its public trades and quotes, +for every venue whose stream the server's credentials open. It keeps one +connection per venue, shared by every client and by the engine's stops, and +reference-counts markets on it. The wire format is in +[docs/server.md](server.md#live-market-data-wsmarket). + ## What the streams promise **Subscriptions survive reconnects.** `watch_*` records what you asked for; diff --git a/docs/trading.md b/docs/trading.md index 27a5edb..55b6a8d 100644 --- a/docs/trading.md +++ b/docs/trading.md @@ -260,6 +260,47 @@ by hand. `InsufficientFunds`, `MarketHalted`. Every `ExchangeError` carries the venue's parsed `body` and HTTP `status`. +**Combos and RFQ** (`rfq_taker`, `rfq_quoter`, `combos`). A combo is a market +created in a multivariate event collection: an ordinary binary market with a +`KXMVE…` ticker, its own book, positions and settlement, so its id is +`kalshi:KXMVE…`. `fetch_combo_collections()` lists the collections (which +events each takes, always as `associated_event_tickers`, plus +`associated_events`, `size_min`/`size_max`, `active_quoters`). +`create_combo([ComboLeg(market_id=..., outcome="yes"|"no", event_id=...), ...], collection_id=...)` +returns the combo market for those legs, creating it if needed; without +`collection_id` the one open collection that takes every leg's event is used, +and a leg without `event_id` has its market read for it. Several fitting +collections are `InvalidOrder` naming them (and listing them on +`exc.collections`); pass one as `collection_id`. Today's three open +collections (`KXMVESPORTSMULTIGAMEEXTENDED-R`, `KXMVECROSSCATEGORY-R`, +`KXMVECROSSCATEGORY-SHARD1-R`) take the same events, so name one. Creation is signed +and capped at 5,000 a week, and a combo must be created once before it can +be traded or read. `fetch_combo(id)` reads one, legs from `mve_selected_legs`; +there is no lookup by legs that does not create. + +An RFQ is two-sided: `create_rfq(market_or_combo_id, quantity=...)` (sent as +`contracts_fp`) or `amount=` (USD, sent as `target_cost_dollars`). By default +the target cost covers principal and Kalshi's fees, so each quote's size +shrinks to fit the fees; `target_cost_excludes_fees=True` makes it principal +only, with the taker fee on top. At most 100 open RFQs, one per market. A +quote is two bids: `RfqQuote.bid` is the maker's `yes_bid`, `ask` is +`1 - no_bid`, a "0" bid a declined side (`None`). +`accept_quote(AcceptQuote(quote_id=, rfq_id=, outcome=, action=, worst_price=))` +sends `accepted_side`, which names the maker's bid you take: buying YES sends +`no` (at `1 - no_bid`), buying NO sends `yes` (at `1 - yes_bid`). It reads the +quote first and refuses, before sending, a quote that is not open, a declined +side, or a price worse than `worst_price`; REST takes the full size only. The +maker confirms within 30 s (3 s on combos), the orders follow, and +`taker_order_id` (`rfq_creator_order_id`) is your order; its fills are in +`fetch_my_trades`. As a quoter: `fetch_incoming_rfqs`, +`send_quote(MakerQuote(rfq_id=, bid=, ask=, post_only=))` (sent as +`yes_bid = bid`, `no_bid = 1 - ask`, the RFQ's full size), `cancel_quote`, +`confirm_quote` and `fetch_my_quotes`. Kalshi has no replace, so +`replace_quote` deletes the old quote and sends the new one: for one round +trip the RFQ has no quote of yours. A quote holds collateral when it is +sent; too little is `InsufficientFunds` (`insufficient_balance`). See +[RFQ and combos](rfq-combo.md). + ### Running against the demo ```bash @@ -304,10 +345,11 @@ signature and hash for the four wallet types on both exchanges, the L1 auth signature, the L2 HMAC, and 1,140 limit-order amount cases. **Two tokens, one leg.** YES and NO are separate tokens with separate books -and no netting, so the adapter picks the token from the side: `buy` buys the -YES token at `price`; `sell` buys the NO token at `1 - price`, because you -cannot sell YES you do not hold. With `reduce_only`, `sell` sells the YES -tokens held instead and `buy` sells NO tokens held. Everything the venue +and no netting, so an order says which token it trades with `outcome` and +`action`: Buy YES buys the YES token at `price`, Sell YES sells YES tokens +held, Buy NO buys the NO token at `1 - price`, Sell NO sells NO tokens held. +An order with neither takes its side: `buy` buys YES, `sell` buys NO, +because you cannot sell YES you do not hold. Everything the venue reports on the NO token comes back on the YES leg (a NO buy at 0.30 reads as a sell at 0.70), and a position is the two inventories netted with both kept on `inventory_yes` / `inventory_no`. Which token is which side comes @@ -316,7 +358,7 @@ from the Gamma catalog, one read per market, cached. **What the venue holds.** Limit orders, `gtc`, `gtd`, and `ioc` / `fok` (the venue's FAK and FOK). A market order is an immediate limit at your protection price. Post-only for `gtc` and `gtd`. No amend (`edit_order` is -`False`: a change is a cancel and a new signed order), no reduce-only, no +`False`: a change is a cancel and a new signed order), no client order id -- but the order id is the order's hash, computed before it is sent. A `gtd` order is sent with the venue's one-minute early expiry added back, so it expires when you asked, and must live at least two @@ -355,6 +397,90 @@ map to `InsufficientFunds`, `InvalidOrder` (tick, minimum size, expiry), `deposit_wallet_required` or `funder_not_signers_wallet`, each with what to change). +**Combos and RFQ** (`rfq_taker`, `rfq_quoter`, `combos`). A combo is 2–50 +leg outcomes of combo-enabled markets with its own YES/NO pair, traded only +by RFQ and settled through **Exchange v3** (`0xe3333700…00Aa`), not the CLOB. +No credential beyond the CLOB API key is needed: both RFQ gateways take the +same L2 headers. The requester side refuses EOA wallets +(`UNSUPPORTED_REQUESTER_SIGNATURE_TYPE`, also checked before anything is +sent): request from a Deposit Wallet (3), proxy (1) or Safe (2). Quoting +works for all four types. Quoting with last look also needs the venue's sign-off. + +Approvals, once per wallet, from the wallet (`maker_address`), with +`approve_combo_trading(include_router=False, include_auto_redeem=False)`: + +| Approval | Who needs it | Flag | +|---|---|---| +| pUSD `approve(Exchange v3)`, PositionManager `setApprovalForAll(Exchange v3)` | quoters, when the quoted order moves pUSD or combo positions | always sent | +| pUSD `approve(Router)`, PositionManager `setApprovalForAll(Router)` (`0x12121212006e4CD160D18e3f00711DA5c3372600`) | anyone splitting pUSD into positions, or managing or redeeming them through the Router | `include_router=True` | +| PositionManager `setApprovalForAll(AutoRedeemer)` (`0xa1200000d0002264C9a1698e001292D00E1b00af`) | anyone wanting automatic redemption | `include_auto_redeem=True` | + +The venue's market-makers guide lists these for quoters. For requesters it +says nothing: whether a requester needs the Exchange v3 approvals (a BUY +spends pUSD, a SELL moves combo positions) is UNVERIFIED. + +- **Combos.** The venue has no create or lookup endpoint. `create_combo([ComboLeg(market_id="polymarket:", outcome="yes"|"no"), ...])` + is offline. It maps each leg to its combo position id (from + `fetch_combo_markets()`, the public catalogue; from `leg.info["position_id"]`; + or from Gamma) and returns the combo under a provisional id, + `polymarket:combo-legs-.`, which spells out its legs. The first + RFQ's answer gives the combo's real id, `polymarket:combo-`; + `create_combo` and `fetch_combo` return that id from then on, and positions + and fills live on it. A `collection_id` is refused (no collections). +- **Eligible markets.** `fetch_combo_eligible_markets(tag=, limit=, cursor=)` + is the catalogue as `ComboMarket` rows (`market_id`, `title`, + `position_ids={"yes", "no"}`, `max_legs` 50, `tags`, the row in `info`), by + volume. The catalogue takes no filter, so `tag` is applied to each page + (a page can be short); `event_id` is `BadRequest` (the catalogue names no + events). `fetch_combo_markets()` stays the raw rows. +- **One side per RFQ.** `create_rfq(combo_id, outcome="yes", action="buy", amount=...)` + sizes a buy in pUSD, fees included. `action="sell", quantity=...` sizes a + sell in shares. The venue quotes the combo's YES only. The call returns + once the 400 ms competition is over, with the best quote; `fetch_quotes` + returns it as `ask` (for a buy) or `bid` (for a sell), the blended YES + price, with the shares traded. An answer with no quote is a closed RFQ + with `info["error"]` (`NO_QUOTES`). +- **Accept** within the quote's `expires_at` (5–10 s): + `accept_quote(AcceptQuote(quote_id=, rfq_id=, outcome="yes", action=, worst_price=))` + signs an Exchange v3 order for the quote's exact amounts. It is refused + before signing if the blended price or the order's own price is worse + than `worst_price`; fees come on top (`info["total_required"]`). Only the + full size can be accepted. A retry never sends twice. The quote then + moves `accepted` → `confirmed` (on its way to the chain) → `executed` + (FILLED/CONFIRMED). Its order is `rfq:`: `fetch_order` and + `fetch_my_trades` answer for it from the RFQ, because the trade never + touches the CLOB. +- **What the gateway lacks.** It has no list of your RFQs and no cancel. + `fetch_rfqs` lists the RFQs this adapter created in this process, and + `cancel_rfq` drops one locally (an unaccepted RFQ lapses at `expires_at`). + Before an accept, status reads answer 409, so an RFQ is read from its + create answer until then. +- **No idempotency key on create.** A create that timed out may have made an + RFQ whose id you never got. Nothing can trade on it, but retrying makes a + second RFQ, using up the 15 creates a minute. The engine journals each + create and raises `SubmissionInDoubt`. `create_rfq` returns an open RFQ + it already holds for the same request instead of making a new one. After + a restart, `remember_rfq(rfq)` re-seeds an RFQ from its `Rfq.info`, and + `restore_rfqs(rfqs, quotes)` (which the engine calls from `recover()`) + re-seeds its open RFQs and the quotes whose accept or confirm was in + doubt, so an in-flight accept is followed to FILLED or FAILED. +- **Quoting.** Quote requests arrive only on the quoter WebSocket. + `await poly.start_rfq_session(on_rfq=...)` opens it, and quotes are due + within 400 ms, so answer from `on_rfq` (or `subscribe_incoming_rfqs`, + which the engine uses: `engine.rfq.on_incoming_rfq(callback)`) rather + than a poll. `start_incoming_rfqs()` opens the session if it is not open. + `fetch_incoming_rfqs` lists the requests received that are still open. + `send_quote(MakerQuote(rfq_id=, ask=|bid=, quantity=, expires_at=))` + prices the request's one side: the `ask` for a buy request (filled by + buying NO at `1 - ask`), the `bid` for a sell request. It signs the + Exchange v3 order and posts it over REST. A buy quote above 0.90909 is + refused. `cancel_quote` withdraws a quote, or declines it while its last + look is open. `confirm_quote` is the last look (1 s). `replace_quote` + cancels the old quote, then sends a new one. Your fill is + `rfq::`. + +See [RFQ and combos](rfq-combo.md). + ## Polymarket US Two APIs trade the same exchange. @@ -382,6 +508,41 @@ are notional units, `price_scale * fractional_quantity_scale` to the dollar, negative for a rebate. Its order, execution, position, drop-copy, market-data and balance-ledger streams are gRPC; see [Streaming](streaming.md#polymarket-us-exchange-api-grpc). +**Combos and RFQ** (retail API; `rfq_taker`, `rfq_quoter`, `combos`). A combo +is an instrument of 2 to 50 legs with its own symbol, so its id is +`polymarket_us:caoc-…` (or `crossd-…`) and it trades, holds a position and +settles like any market. `create_combo([ComboLeg(market_id=..., outcome="yes"|"no"), ...])` +returns the venue's canonical combo for those legs (`no` sells the leg); the +same legs always give the same symbol, so it is safe to call again. +`fetch_combo(id)` reads one by symbol; there is no lookup by legs that does +not create. A `collection_id` is refused before anything is sent. +`fetch_combo_eligible_markets(event_id=, tag=, limit=, cursor=)` lists the +`comboEnabled` markets of the public gateway's open events (`GET /v1/events`, +no auth), newest event first, as `ComboMarket` rows with `event_id` +(`polymarket_us:`), `event_title`, `max_legs` 50 and the venue's +`status`. `event_id` and `tag` (a tag slug: `nfl`, `soccer`) are the venue's +filters; `limit` counts events read (default 50), so a page can hold no +markets yet carry a cursor (an offset). + +An RFQ is two-sided: `create_rfq(combo_id, quantity=...)` or `amount=` (USD, +sent as `cashOrderQty`; the venue sizes each side to it, taker fee +included). Quotes come back from `fetch_quotes(rfq_id)` as `RfqQuote` in YES +prices: `ask` is the venue's `buyPrice`, `bid` its `sellPrice`, a declined +side `None`. `accept_quote(AcceptQuote(quote_id=, rfq_id=, outcome=, action=, worst_price=))` +sends `acceptedSide` as the requester's own side: buy YES (or sell NO) is +`SIDE_BUY` at the ask, sell YES (or buy NO) `SIDE_SELL` at the bid. It reads +the quote first and refuses, before sending, a quote that is not active, a +declined side, or a price worse than `worst_price`; a retry of an accept +already made is answered from the quote without resending. It returns at +once with the quote `accepted`. The maker then has 3 s to confirm; `executed` +means the two orders were submitted (the requester's id is +`taker_order_id`), not that they filled; read fills from the order. RFQs do +not expire: `cancel_rfq` closes one. As a quoter: `fetch_incoming_rfqs`, +`send_quote(MakerQuote(rfq_id=, bid=, ask=, post_only=))` (no quantity: the +RFQ's size), `replace_quote` (a new id), `cancel_quote` (also declines in the +last look) and `confirm_quote`. Combo and RFQ creation share 1 request a +second. See [RFQ and combos](rfq-combo.md). + On both, entry is asynchronous: create, cancel and modify return an id or nothing, and the result is `pending`, `pending_cancel` or `pending_replace` until read back. The venue's "Global Rate Limit Exceeded" reject is its @@ -397,8 +558,8 @@ signing and amounts checked byte for byte against the SDK's code. Orders are the CTF exchange's EIP-712 `Order` on BNB Chain, signed by the wallet for the account's Safe, which holds the USDT and the tokens; the Safe's address is read from the venue when not configured. YES and NO are -separate tokens, as on Polymarket: `buy` buys YES, `sell` buys NO at -`1 - price`, and with `reduce_only` they sell the tokens held instead. +separate tokens, as on Polymarket: an order buys or sells the token its +`outcome` and `action` name (with neither, `buy` buys YES, `sell` buys NO). The venue rests limit orders until cancelled and has nothing else: no time in force, no post-only, and a market buy that spends a USDT amount rather @@ -424,8 +585,8 @@ US among them: check them before trading. the venue SDK's own test vectors. An outcome trades as two coins, YES and NO, as on Polymarket: `buy` buys the -YES coin, `sell` buys the NO coin at `1 - price`, and with `reduce_only` they -sell the coin held instead. Orders are L1 actions signed by the key in +YES coin, `sell` buys the NO coin at `1 - price`, and an order's `outcome` +and `action` (Sell YES, Sell NO) sell the coin held instead. Orders are L1 actions signed by the key in `HYPERLIQUID_PRIVATE_KEY` for the account; with an API wallet (approved on app.hyperliquid.xyz under More -> API), `HYPERLIQUID_ACCOUNT_ADDRESS` names the account, since its orders, fills and balances are what is read. @@ -460,8 +621,8 @@ of four exchanges by the market's kind (neg-risk or not, yield-bearing or not). A plain wallet signs its own orders. A Predict account (the smart wallet the web app makes) is the orders' maker and holds the USDT; its owner key signs for it, wrapped for the account's Kernel validator. YES and NO are -separate tokens: `buy` buys YES, `sell` buys NO at `1 - price`, and with -`reduce_only` they sell the tokens held instead. +separate tokens: an order buys or sells the token its `outcome` and +`action` name (with neither, `buy` buys YES, `sell` buys NO). Orders, cancels and account reads need a login token, which the adapter gets by signing the venue's login message with the same key, and renews when the @@ -505,8 +666,8 @@ also signed with a scoped API token. The profile must be in EOA trading mode: an account that once turned on the web app's one-click smart wallet rejects orders its own wallet signs, until `use_eoa_trading_mode()` switches it back (the web app's one-click trading then stops until it is switched again -there). YES and NO are separate tokens: `buy` buys YES, `sell` buys NO at -`1 - price`, and with `reduce_only` they sell the tokens held instead. +there). YES and NO are separate tokens: an order buys or sells the token +its `outcome` and `action` name (with neither, `buy` buys YES, `sell` buys NO). Limits rest until cancelled (`gtc`), post-only on request. `market` and `ioc` go as the venue's fill-and-kill at the price given as the worst accepted. The @@ -550,7 +711,7 @@ price given; `sell` bets outcome two at `1 - price`. The venue counts a stake in USDC, so `n` contracts stake `n * price` (or `n * (1 - price)`), at least 1 USDC, and prices are snapped onto the venue's odds ladder (0.125% today) on the cheaper side, so an order never pays more than asked. Every bet on a -market nets, so `reduce_only` is refused. +market nets. Limits rest until cancelled (`gtc`), post-only on request, or until `expires_at` (`gtd`). `market` and `ioc` go as the venue's `IOC` at the price @@ -572,4 +733,44 @@ then escrowed or failed on chain ([Streaming](streaming.md)). Positions net each market on the YES leg. The balance is the proxy wallet's USDC: what is free to bet, and what matched bets hold in escrow. +**Parlays (quoter side).** A bettor's parlay request on sx.bet makes a real +market, the parlay's own id (`sx_bet:{parent hash}`), that takes makers' +orders for 60 seconds. `SxBetTrading` hears each request on +`parlay_markets:global` (`SxBetParlayStream`, the same API key) and keeps it as +an incoming `Rfq` (`mine=False`): a two-sided request on a `Combo` of its legs +(`yes` where the bettor needs outcome one of a leg), sized by `amount`, the +stake asked for (`requestSize`, which does not cap what a maker offers). The +venue replays nothing and lists no open requests over REST, so +`fetch_incoming_rfqs` returns the requests heard and still open (its first +call starts the stream, as does `start_incoming_rfqs()`); +`subscribe_incoming_rfqs(listener)` hears each one at once, and the engine +subscribes there (`engine.rfq.on_incoming_rfq`), so SX Bet is not polled. `fetch_rfq` and `fetch_combo` read the ones heard; `lookup_combo` finds a +heard parlay by its legs; `create_combo` is not supported. + +`send_quote(MakerQuote)` signs ordinary orders on the parlay market: the bid +buys YES (outcome one: the parlay wins), the ask sells YES (outcome two at +`1 - ask`). Without a `quantity`, each side covers the requested stake at the +bettor's price. Both rest as the venue's `GTC` with an expiry no later than +the parlay's end: the request names no end, so it is counted as 60 s from its +arrival less 3 s (`PARLAY_EXPIRY_MARGIN_S`), or `expires_at` if sooner; an +order signed past the end is refused with the exact end +(`PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY`) and re-signed to it once. The +quote's id names both orders (`0xbid~0xask`), so `cancel_quote` cancels both, +`replace_quote` is a cancel and a new send, and `fetch_quote` reads it back: +open while an order rests, executed once one filled and none rests, else +expired or canceled. The bettor takes a quote with FOK or IOC; there is no last +look, so `confirm_quote` is not supported, and no API requests a parlay, so +there is no taker side. A parlay that has ended is refused before anything is +sent; the venue's `INVALID_MARKET` (after the end) and `PARLAY_LEG_INACTIVE` +are `OrderRejected` with reasons `parlay_expired` and `parlay_leg_inactive`. +A parlay's fills are on the parlay market, so they book on the combo's id. + +The documents disagree on a VOID leg: the developer docs say it voids the +parlay and returns every stake; the user guide says a VOID or push leg loses a +parlay WIN bet, and a parlay LOSE bet wins only with no VOID leg. +`parlay_result(legs, outcome_one=)` gives both readings and the worse one for +the side held; Synpath books nothing from a leg itself and takes the venue's +settlement as the record. Parlay fees are separate rates (the user guide says +5% of a winning parlay's profit), not the single-bet estimate. + Check SX Bet's terms for where it may be used before trading. diff --git a/examples/verify_kalshi_stop_fill.py b/examples/verify_kalshi_stop_fill.py new file mode 100644 index 0000000..5f70c57 --- /dev/null +++ b/examples/verify_kalshi_stop_fill.py @@ -0,0 +1,287 @@ +"""Authorized one-contract stop-limit fill test on a 15-minute crypto market. + +Call only with explicit live-trading authorization. The HTTP guard permits +one bounded entry and one Sell YES exit of exactly that entry's filled quantity. +No cancel-all, retries of creates, or use of existing positions is allowed. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import tempfile +import time +import uuid +from datetime import datetime +from decimal import Decimal as D, ROUND_CEILING +from pathlib import Path + +import httpx + +from examples.verify_kalshi_stop_limit import credentials_from, read_child +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.trading.kalshi import KalshiTrading +from synpath.trading.types import OrderRequest, Precision, Side +from synpath.ws.base import LocalBook + +CENT = D("0.01") +MAX_LIMIT = D("0.75") +EXIT_LIMIT = CENT + + +class FillGuard: + def __init__(self, ticker, limit): + self.ticker, self.limit = ticker, limit + self.stage = "blocked" + self.sent = {} + self.rejected = {} + self.exit_quantity = D("0") + self.cancel_ids = set() + + async def check(self, request): + if request.url.host != "external-api.kalshi.com": + raise ValueError("unexpected host") + if request.method == "GET": + return + prefix = "/trade-api/v2/portfolio/events/orders" + if request.method == "DELETE" and request.url.path in {f"{prefix}/{i}" for i in self.cancel_ids}: + return + if request.method != "POST" or request.url.path != prefix or self.stage not in ("entry", "exit") or self.stage in self.sent: + raise ValueError("write exceeds authorized entry/exit") + body = json.loads(request.content) + entry = self.stage == "entry" + quantity = D("1") if entry else self.exit_quantity + price = self.limit if entry else EXIT_LIMIT + # The entry is the stop-limit's child: it lives as long as the stop + # (gtd at the check's deadline), and is cancelled the moment it is + # read back. The exit is an immediate market order. + allowed = {"ticker", "client_order_id", "side", "count", "price", "time_in_force", "self_trade_prevention_type", + "post_only", "expiration_time"} + bounded = (body.get("time_in_force") == "good_till_canceled" and bool(body.get("expiration_time")) + if entry else body.get("time_in_force") == "immediate_or_cancel" and "expiration_time" not in body) + if not (body.get("ticker") == self.ticker and body.get("side") == ("bid" if entry else "ask") + and D(body.get("count", "0")) == quantity and 0 < quantity <= 1 + and D(body.get("price", "0")) == price and (not entry or price <= MAX_LIMIT) + and bounded + and "reduce_only" not in body and body.get("post_only") is False + and not (set(body) - allowed)): + raise ValueError("unexpected order size, price or flags") + self.sent[self.stage] = body + + + async def observe(self, response): + # A definitive refusal is not an ambiguous accepted order. Never + # retry it or wait for it to appear in venue history. + if response.request.method != "POST" or response.status_code not in (400, 401, 403, 422): + return + for stage, body in self.sent.items(): + request_body = json.loads(response.request.content) + if body["client_order_id"] == request_body.get("client_order_id"): + self.rejected[stage] = f"HTTP {response.status_code}" + + +async def book_for(adapter, ticker): + raw = await adapter._call("GET", f"/markets/{ticker}/orderbook", params={"depth": 5}) + rows = raw.get("orderbook_fp") or {} + book = LocalBook() + book.replace([(D(p), D(q)) for p, q in rows.get("yes_dollars", [])], + [(1 - D(p), D(q)) for p, q in rows.get("no_dollars", [])]) + return book + + +async def choose_market(adapter): + for series in ("KXBTC15M", "KXETH15M", "KXSOL15M"): + raw = await adapter._call("GET", "/markets", params={"series_ticker": series, "status": "open", "limit": 10}) + for market in raw.get("markets", []): + closes = datetime.fromisoformat(market["close_time"].replace("Z", "+00:00")).timestamp() + if market.get("status") != "active" or not 300 < closes - time.time() < 1200: + continue + ticker = market["ticker"] + book = await book_for(adapter, ticker) + if (book.best_bid is None or book.best_ask is None or not D("0.10") <= book.best_bid < book.best_ask <= D("0.65")): + continue + if await adapter.fetch_positions(market_id=f"kalshi:{ticker}"): + continue + if await adapter.fetch_open_orders(market_id=f"kalshi:{ticker}"): + continue + trigger = book.best_ask.quantize(CENT, rounding=ROUND_CEILING) + CENT + limit = trigger + D("0.03") + fee = await adapter.fetch_fee_estimate(f"kalshi:{ticker}", Side.BUY, D("0.50"), D("1")) + if fee.taker_fee is None or fee.taker_fee > D("0.02") or limit > MAX_LIMIT: + continue + shard = int(market.get("exchange_index") or 0) + balance = await adapter._call("GET", "/portfolio/balance", params={"exchange_index": shard}) + available = D(str(balance["balance_dollars"])) if balance.get("balance_dollars") is not None else D(str(balance.get("balance", 0))) / 100 + if available < limit + D("0.04"): + continue + return market, book, trigger, limit + raise RuntimeError("no suitable funded crypto market with time, liquidity and an empty starting position") + + +def engine_for(adapter, path): + return Engine({"kalshi": adapter}, EngineConfig(journal_path=str(path), user_id="kalshi-fill-verification"), + risk=RiskConfig(price_collar=None, max_order_contracts=D("1"), max_order_notional=MAX_LIMIT, + closing_soon_s=60, duplicate_window_ms=0)) + + +async def find_sent(adapter, guard, stage): + body = guard.sent.get(stage) + if not body or stage in guard.rejected: + return None + for _ in range(12): + cursor = None + while True: + page = await adapter.fetch_orders(market_id=f"kalshi:{guard.ticker}", limit=200, cursor=cursor) + matching = [o for o in page if o.client_order_id == body["client_order_id"]] + if matching: + return matching[0] + cursor = page.next_cursor + if not cursor: + break + await asyncio.sleep(0.5) + raise RuntimeError("accepted status unresolved; keep the journal for reconciliation") + + +async def cancel_remainder(adapter, guard, stage): + order = await find_sent(adapter, guard, stage) + if order is None: + return None + guard.cancel_ids.add(order.id) + if not order.is_terminal: + await adapter.cancel_order(order.id, market_id=order.market_id) + final = await read_child(adapter, order.id) + if not final.is_terminal: + raise RuntimeError("test order still open after cleanup") + return final + + +async def close_entry(engine, adapter, guard, entry): + if entry.filled <= 0: + return None + # Only the quantity confirmed on the test entry is allowed to close. + guard.exit_quantity = entry.filled + guard.stage = "exit" + try: + await engine.submit(OrderRequest(market_id=entry.market_id, outcome="yes", action="sell", quantity=entry.filled, + type="market", price=EXIT_LIMIT, time_in_force="ioc", + client_order_id=f"close-{uuid.uuid4().hex}", book="bounded-fill-check"), + precision=Precision(tick=CENT, min_quantity=D("0.01"), quantity_step=D("0.01"))) + finally: + guard.stage = "blocked" + return await cancel_remainder(adapter, guard, "exit") + + +async def verify(adapter, guard, directory, market, initial_book, trigger, limit, *, wait_s=240, poll_s=0.5): + market_id = f"kalshi:{market['ticker']}" + report = {"market_id": market_id, "title": market.get("title"), "quantity": "1", "initial_bid": str(initial_book.best_bid), + "initial_ask": str(initial_book.best_ask), "stop_price": str(trigger), "limit_price": str(limit), + "maximum_entry_value_usd": str(limit), "estimated_fee_budget_usd": "0.04"} + engine = engine_for(adapter, directory / "orders.db") + request = OrderRequest(market_id=market_id, side="buy", quantity="1", type="stop_limit", stop_price=trigger, + price=limit, time_in_force="gtd", expires_at=int((time.time() + wait_s) * 1000), + client_order_id=f"stop-fill-{uuid.uuid4().hex}", book="bounded-fill-check") + parent_id = None + try: + await engine.start() + engine.set_book(market_id, initial_book) + parent = await engine.submit(request) + parent_id = parent.id + await engine.on_book({"market_id": market_id}) + if engine.managed_order_controller.get(parent_id).state != "waiting" or guard.sent: + raise RuntimeError("stop was not below its trigger when armed") + await engine.stop() + engine = engine_for(adapter, directory / "orders.db") + await engine.start() + assert (await engine.submit(request)).id == parent_id + engine._tasks.append(asyncio.create_task(engine._lease_loop())) + report["waiting_restart_verified"] = True + guard.stage = "entry" + deadline = time.monotonic() + wait_s + while engine.managed_order_controller.get(parent_id).state == "waiting" and time.monotonic() < deadline: + book = await book_for(adapter, guard.ticker) + engine.set_book(market_id, book) + await engine.on_book({"market_id": market_id}) + await engine.managed_order_controller.on_timer() + parent = engine.managed_order_controller.get(parent_id) + if parent.state != "waiting": + report["observed_trigger_ask"] = str(book.best_ask) + break + await asyncio.sleep(poll_s) + guard.stage = "blocked" + entry = await cancel_remainder(adapter, guard, "entry") + if entry is None: + await engine.cancel(parent_id) + report["outcome"] = ("venue rejected the entry; no fill" if "entry" in guard.rejected + else "no threshold crossing; no venue order sent") + report["venue_refusal"] = guard.rejected.get("entry") + return report + await engine.on_order(entry) + report.update(entry_status=entry.status.value, entry_filled=str(entry.filled), entry_average_price=str(entry.average_price), + entry_cost=str(entry.cost), entry_fee=str(entry.fee), parent_status=engine.managed_order_controller.get(parent_id).as_order().status.value) + fills = await adapter.fetch_my_trades(market_id=market_id, order_id=entry.id) + report["entry_fill_records"] = [{"quantity": str(f.quantity), "price": str(f.price), "fee": str(f.fee)} for f in fills] + if entry.filled <= 0: + report["outcome"] = "triggered but no fill before the entry was cancelled" + return report + exit_order = await close_entry(engine, adapter, guard, entry) + if exit_order: + await engine.on_order(exit_order) + report.update(exit_status=exit_order.status.value, exit_filled=str(exit_order.filled), + exit_average_price=str(exit_order.average_price), exit_cost=str(exit_order.cost), exit_fee=str(exit_order.fee)) + for _ in range(12): + positions = await adapter.fetch_positions(market_id=market_id) + if not positions: + break + await asyncio.sleep(0.5) + report["remaining_position_contracts"] = str(sum((p.contracts for p in positions), D("0"))) + report["outcome"] = "filled and closed" if not positions else "filled; residual position held until market settlement" + await engine.stop() + engine = engine_for(adapter, directory / "orders.db") + await engine.start() + replay = await engine.submit(request) + report["terminal_restart_verified"] = replay.id == parent_id and replay.is_terminal + return report + finally: + guard.stage = "blocked" + try: + if parent_id and engine.managed_order_controller.get(parent_id) and engine.managed_order_controller.get(parent_id).state == "waiting": + await engine.cancel(parent_id) + # The exit is IOC and already terminal; the entry may rest. Read back and cancel only + # these keys even after a failed/ambiguous response. Never resend. + for stage in ("entry", "exit"): + final = await cancel_remainder(adapter, guard, stage) + if final: + report[f"final_{stage}_status"] = final.status.value + report[f"final_{stage}_filled"] = str(final.filled) + report["creates_attempted"] = len(guard.sent) + report["cleanup_verified"] = True + finally: + await engine.stop() + (directory / "result.json").write_text(json.dumps(report, indent=2) + "\n") + + +async def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--credentials-dir", type=Path, required=True) + parser.add_argument("--execute", action="store_true") + args = parser.parse_args() + directory = Path(tempfile.mkdtemp(prefix="synpath-kalshi-fill-")) + os.chmod(directory, 0o700) + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: + async with KalshiTrading(credentials_from(args.credentials_dir), client=client) as adapter: + adapter.http.attempts = 1 + market, book, trigger, limit = await choose_market(adapter) + guard = FillGuard(market["ticker"], limit) + client.event_hooks["request"].append(guard.check) + client.event_hooks["response"].append(guard.observe) + print(json.dumps({"selected_market": market["ticker"], "title": market.get("title"), "current_ask": str(book.best_ask), + "stop_price": str(trigger), "limit_price": str(limit), "quantity": "1"}), flush=True) + if args.execute: + report = await verify(adapter, guard, directory, market, book, trigger, limit) + print(json.dumps(report, indent=2)) + print(f"Private records: {directory}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/verify_kalshi_stop_limit.py b/examples/verify_kalshi_stop_limit.py new file mode 100644 index 0000000..a54e8c3 --- /dev/null +++ b/examples/verify_kalshi_stop_limit.py @@ -0,0 +1,241 @@ +"""One explicitly authorized, bounded Kalshi stop-limit check. + +Without --execute, this script only reads the market and fees. With it, the +only permitted create is one YES contract at 1 cent, post-only, expiring +within 90 seconds. Cleanup targets only that child's verified identity. +Credentials and the private SQLite journal are never printed or committed. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import tempfile +import time +import uuid +from decimal import Decimal as D +from pathlib import Path + +import httpx + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.trading.credentials import load_credentials, read_dotenv +from synpath.trading.errors import OrderNotFound +from synpath.trading.kalshi import KalshiTrading +from synpath.trading.types import OrderRequest, OrderStatus, Side +from synpath.ws.base import LocalBook + +TICKER = "KXFEDDECISION-27JAN-H0" +MARKET = f"kalshi:{TICKER}" +PRICE = D("0.01") + + +class OneOrderGuard: + """Enforce the authorization on the actual HTTP payload, not a preview.""" + + def __init__(self): + self.enabled = False + self.sent: dict | None = None + self.cancel_ids: set[str] = set() + self.created_at = time.time() + + async def check(self, request: httpx.Request) -> None: + if request.url.host != "external-api.kalshi.com": + raise ValueError("unexpected Kalshi host") + if request.method == "GET": + return + prefix = "/trade-api/v2/portfolio/events/orders" + if request.method == "DELETE" and request.url.path in {f"{prefix}/{oid}" for oid in self.cancel_ids}: + return + if request.method != "POST" or request.url.path != prefix or not self.enabled or self.sent is not None: + raise ValueError("write exceeds the single-order authorization") + body = json.loads(request.content) + expiry = body.get("expiration_time", 0) + if not (body.get("ticker") == TICKER and body.get("side") == "bid" + and D(body.get("count", "0")) == 1 and D(body.get("price", "0")) == PRICE + and body.get("post_only") is True and "reduce_only" not in body + and body.get("time_in_force") == "good_till_canceled" + and time.time() < expiry <= self.created_at + 90 + and not (set(body) - {"ticker", "client_order_id", "side", "count", "price", "time_in_force", + "self_trade_prevention_type", "post_only", "expiration_time"})): + raise ValueError("unexpected size, price, flags or expiry") + self.sent = body # reserve before the network; never retry an uncertain send + + +def credentials_from(directory: Path): + values = {k: v for k, v in read_dotenv(directory / ".env").items() if k.startswith("KALSHI_")} + path = Path(values.get("KALSHI_PRIVATE_KEY_PATH", "")).expanduser() + if not path.is_absolute(): + path = directory / path + if not path.is_file(): + path = directory / "kalshi-prod.pem" + values["KALSHI_PRIVATE_KEY_PATH"] = str(path) + credentials = load_credentials(env=values, dotenv=directory / "__no_extra_credentials__")["kalshi"] + if credentials is None or credentials.env != "prod": + raise ValueError("this bounded check requires the authorized production Kalshi credentials") + return credentials + + +async def read_book(adapter: KalshiTrading) -> LocalBook: + raw = await adapter._call("GET", f"/markets/{TICKER}/orderbook", params={"depth": 5}) + levels = raw.get("orderbook_fp") or {} + book = LocalBook() + book.replace([(D(p), D(q)) for p, q in levels.get("yes_dollars", [])], + [(D("1") - D(p), D(q)) for p, q in levels.get("no_dollars", [])]) + if book.best_bid is None or book.best_ask is None or not PRICE < book.best_bid < book.best_ask < 1: + raise ValueError("no suitable uncrossed book above the one-cent test limit") + return book + + +async def preflight(adapter: KalshiTrading) -> dict: + market = (await adapter._call("GET", f"/markets/{TICKER}"))["market"] + if market.get("status") != "active" or market.get("price_level_structure") != "linear_cent": + raise ValueError("market is not active on the expected cent grid") + fee = await adapter.fetch_fee_estimate(MARKET, Side.BUY, PRICE, D("1")) + if fee.maker_fee is None or fee.maker_fee > PRICE: + raise ValueError("fee estimate exceeds the small test budget") + book = await read_book(adapter) + return {"market_id": MARKET, "quantity": "1", "limit_price": str(PRICE), + "estimated_maximum_cost_usd": str(PRICE + fee.maker_fee), + "observed_bid": str(book.best_bid), "observed_ask": str(book.best_ask)} + + +def new_engine(adapter: KalshiTrading, path: Path) -> Engine: + return Engine({"kalshi": adapter}, EngineConfig(journal_path=str(path), user_id="kalshi-stop-verification"), + risk=RiskConfig(price_collar=None, max_order_contracts=D("1"), max_order_notional=PRICE, + closing_soon_s=None, duplicate_window_ms=0)) + + +async def read_child(adapter: KalshiTrading, order_id: str): + """Wait for the read projection without repeating the accepted create.""" + for attempt in range(12): + try: + return await adapter.fetch_order(order_id, attempts=1) + except OrderNotFound: + if attempt == 11: + raise + await asyncio.sleep(0.5) + + +async def cleanup(adapter: KalshiTrading, guard: OneOrderGuard) -> dict: + """Find even a response-lost child, cancel it, and read the venue back.""" + if guard.sent is None: + return {"child_created": False, "cleanup_verified": True, "filled": "0"} + for attempt in range(12): + orders = [] + cursor = None + while True: + page = await adapter.fetch_orders(market_id=MARKET, cursor=cursor, limit=200) + orders.extend(o for o in page if o.client_order_id == guard.sent["client_order_id"]) + cursor = page.next_cursor + if not cursor: + break + if len(orders) > 1: + raise RuntimeError("multiple children share the verification key") + if orders: + order = orders[0] + guard.cancel_ids.add(order.id) + if not order.is_terminal: + await adapter.cancel_order(order.id, market_id=MARKET) + final = await read_child(adapter, order.id) + if final.is_terminal and (final.remaining or D("0")) == 0: + return {"child_created": True, "venue_status": final.status.value, + "filled": str(final.filled), "cleanup_verified": True} + # Canceled reads may report canceled quantity as remaining. + if final.status == OrderStatus.CANCELED: + return {"child_created": True, "venue_status": final.status.value, + "filled": str(final.filled), "cleanup_verified": True} + await asyncio.sleep(0.5) + raise RuntimeError("child cleanup not confirmed; inspect the retained journal and client key") + + +async def verify(adapter: KalshiTrading, guard: OneOrderGuard, directory: Path) -> dict: + report = await preflight(adapter) + path = directory / "orders.db" + engine = new_engine(adapter, path) + parent_id = None + request = OrderRequest(market_id=MARKET, side="buy", quantity="1", type="stop_limit", + stop_price=PRICE, price=PRICE, post_only=True, time_in_force="gtd", + expires_at=int((guard.created_at + 85) * 1000), + client_order_id=f"verify-{uuid.uuid4().hex}", book="bounded-stop-check") + try: + await engine.start() + parent = await engine.submit(request) + parent_id = parent.id + if parent.status != OrderStatus.WAITING or guard.sent is not None: + raise RuntimeError("arming sent an order or failed to remain waiting") + await engine.stop() + engine = new_engine(adapter, path) + await engine.start() + replay = await engine.submit(request) + if replay.id != parent_id or replay.status != OrderStatus.WAITING or guard.sent is not None: + raise RuntimeError("waiting restart did not restore exactly one parent") + report["waiting_restart_verified"] = True + engine.set_book(MARKET, await read_book(adapter)) + guard.enabled = True + try: + # The actual ask is above the deliberately low trigger. No fake + # price is injected; this intentionally triggers on first observation. + await engine.on_book({"market_id": MARKET}) + finally: + guard.enabled = False + managed = engine.managed_order_controller.get(parent_id) + if managed.state != "working" or len(managed.children) != 1 or guard.sent is None: + raise RuntimeError("trigger did not create exactly one child") + child = managed.children[0] + guard.cancel_ids.add(child.order_id) + live = await read_child(adapter, child.order_id) + if live.client_order_id != guard.sent["client_order_id"] or live.quantity != 1 or live.price != PRICE: + raise RuntimeError("venue read-back differs from the guarded request") + report["child_venue_status"] = live.status.value + report["trigger_and_child_verified"] = True + await engine.cancel(parent_id) + finally: + guard.enabled = False + try: + managed = engine.managed_order_controller.get(parent_id) if parent_id else None + if managed is not None and managed.live and managed.children: + guard.cancel_ids.update(c.order_id for c in managed.children) + try: + await engine.cancel(parent_id) + except Exception as error: + # History-based cleanup still runs if the engine cannot + # complete its cancellation. Never print credential data. + report["engine_cleanup_error"] = type(error).__name__ + report.update(await cleanup(adapter, guard)) + finally: + await engine.stop() + (directory / "result.json").write_text(json.dumps(report, indent=2) + "\n") + engine = new_engine(adapter, path) + try: + await engine.start() + replay = await engine.submit(request) + if replay.id != parent_id or not replay.is_terminal: + raise RuntimeError("terminal restart did not retain the completed parent") + report["terminal_restart_verified"] = True + report["parent_status"] = replay.status.value + finally: + await engine.stop() + (directory / "result.json").write_text(json.dumps(report, indent=2) + "\n") + return report + + +async def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--credentials-dir", type=Path, required=True) + parser.add_argument("--execute", action="store_true") + args = parser.parse_args() + guard = OneOrderGuard() + directory = Path(tempfile.mkdtemp(prefix="synpath-kalshi-stop-")) + os.chmod(directory, 0o700) + async with httpx.AsyncClient(timeout=10, follow_redirects=False, event_hooks={"request": [guard.check]}) as client: + async with KalshiTrading(credentials_from(args.credentials_dir), client=client) as adapter: + adapter.http.attempts = 1 + report = await verify(adapter, guard, directory) if args.execute else await preflight(adapter) + print(json.dumps(report, indent=2)) + print(f"Private verification records: {directory}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/verify_polymarket_us_stop_fill.py b/examples/verify_polymarket_us_stop_fill.py new file mode 100644 index 0000000..3598a62 --- /dev/null +++ b/examples/verify_polymarket_us_stop_fill.py @@ -0,0 +1,285 @@ +"""Explicitly authorized fractional stop-market test using the retail API. + +One entry of 0.01 contract, then native close-position only after an empty +starting position and a matching filled child are verified. All other writes +are blocked. Without --execute this only checks funds, public books and preview. +""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import tempfile +import time +import uuid +from decimal import Decimal as D +from pathlib import Path + +import httpx + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.trading.credentials import read_dotenv, load_credentials +from synpath.trading.errors import OrderNotFound +from synpath.trading.instruments import MarketSpec +from synpath.trading.polymarket_us import PolymarketUSTrading +from synpath.trading.types import OrderRequest, Side +from synpath.ws.base import LocalBook + +QUANTITY = D("0.01") +BOUND = D("0.99") + + +class Guard: + def __init__(self, slug, directory): + self.slug, self.directory = slug, directory + self.stage = "blocked" + self.sent = {} + self.ids = {} + self.cancel_ids = set() + + def entry_ok(self, body): + return (body.get("marketSlug") == self.slug and D(str(body.get("quantity", 0))) == QUANTITY + and D(str((body.get("price") or {}).get("value", 0))) == BOUND + and (body.get("price") or {}).get("currency") == "USD" + and body.get("type") == "ORDER_TYPE_LIMIT" and body.get("outcomeSide") == "OUTCOME_SIDE_YES" + and body.get("action") == "ORDER_ACTION_BUY" and body.get("tif") == "TIME_IN_FORCE_IMMEDIATE_OR_CANCEL" + and not (set(body) - {"marketSlug", "type", "price", "quantity", "tif", "outcomeSide", "action", "manualOrderIndicator"})) + + async def check(self, request): + if request.url.host not in ("api.polymarket.us", "gateway.polymarket.us"): + raise ValueError("unexpected host") + if request.method == "GET": + return + body = json.loads(request.content) + path = request.url.path + if request.method == "POST" and path == "/v1/order/preview" and self.entry_ok(body.get("request") or {}): + return + if request.method == "POST" and path in {f"/v1/order/{oid}/cancel" for oid in self.cancel_ids} and body.get("marketSlug") == self.slug: + return + if self.stage in self.sent or request.method != "POST": + raise ValueError("write exceeds authorization") + if self.stage == "entry" and path == "/v1/orders" and self.entry_ok(body): + self.sent["entry"] = body + elif self.stage == "close" and path == "/v1/order/close-position" and body == { + "marketSlug": self.slug, "manualOrderIndicator": "MANUAL_ORDER_INDICATOR_AUTOMATIC"}: + self.sent["close"] = body + else: + raise ValueError("unexpected order or cleanup") + + async def observe(self, response): + if response.request.method != "POST" or response.status_code >= 400: + return + stage = {"/v1/orders": "entry", "/v1/order/close-position": "close"}.get(response.request.url.path) + if stage: + await response.aread() + raw = response.json() + oid = raw.get("id") or (((raw.get("executions") or [{}])[-1].get("order") or {}).get("id")) + if oid: + self.ids[stage] = oid + # Retain the response identity separately for targeted cleanup. + with (self.directory / "receipts.json").open("w") as stream: + json.dump(self.ids, stream) + stream.flush() + os.fsync(stream.fileno()) + + +def credentials_from(directory): + names = {"POLYMARKET_US_KEY_ID", "POLYMARKET_US_SECRET_KEY"} + values = {k: v for k, v in read_dotenv(directory / ".env").items() if k in names} + creds = load_credentials(env=values, dotenv=directory / "__no_extra_credentials__")["polymarket_us"] + if creds is None: + raise ValueError("Polymarket US credentials missing") + return creds + + +async def positions_for(adapter, market_id): + return [p for p in await adapter.fetch_positions(market_id=market_id) if p.market_id == market_id] + + +async def book_for(adapter, slug): + raw = await adapter.gateway.get(f"/v1/markets/{slug}/book") + raw = raw.get("marketData") or raw + def levels(rows): + return [(D(str(row["px"]["value"])), D(str(row["qty"]))) for row in rows or [] if D(str(row["qty"])) > 0] + book = LocalBook() + book.replace(levels(raw.get("bids")), levels(raw.get("offers"))) + return book + + +async def choose_market(adapter): + balance = await adapter.fetch_balance() + if balance.available is None or balance.available < D("0.05"): + raise RuntimeError("insufficient funds for the tiny test budget") + raw = await adapter.gateway.get("/v1/search", {"query": "up or down", "limit": 10, "page": 1}) + for event in raw.get("events", []): + if "15m" not in event.get("slug", "") or not event.get("live") or event.get("ended"): + continue + for market in event.get("markets", []): + if not market.get("active") or market.get("closed"): + continue + slug = market["slug"] + rules = await adapter.market_rules(slug, refresh=True) + if rules.min_quantity > QUANTITY or rules.tick != D("0.01"): + continue + book = await book_for(adapter, slug) + if book.best_bid is None or book.best_ask is None or not D("0.01") <= book.best_bid < book.best_ask < D("0.95"): + continue + market_id = f"polymarket_us:{slug}" + if await positions_for(adapter, market_id) or await adapter.fetch_open_orders(market_id=market_id): + continue + fee = await adapter.fetch_fee_estimate(market_id, Side.BUY, D("0.50"), QUANTITY) + if fee.taker_fee is None or fee.taker_fee > D("0.01"): + continue + return slug, rules, book, book.best_ask + rules.tick + raise RuntimeError("no suitable liquid live BTC 15-minute market, initially flat") + + +def engine_for(adapter, directory): + return Engine({"polymarket_us": adapter}, EngineConfig(journal_path=str(directory / "orders.db"), user_id="us-stop-verification"), + risk=RiskConfig(price_collar=None, max_order_contracts=QUANTITY, max_order_notional=QUANTITY, + duplicate_window_ms=0, closing_soon_s=None)) + + +async def terminal_order(adapter, oid, guard): + for _ in range(40): + try: + order = await adapter.fetch_order(oid) + if order.is_terminal: + return order + except OrderNotFound: + pass + await asyncio.sleep(0.25) + guard.cancel_ids.add(oid) + await adapter.cancel_order(oid, market_id=f"polymarket_us:{guard.slug}") + for _ in range(40): + order = await adapter.fetch_order(oid) + if order.is_terminal: + return order + await asyncio.sleep(0.25) + raise RuntimeError("test order still unresolved after targeted cancellation") + + +async def verify(adapter, guard, directory, rules, initial, trigger, *, wait_s=240, poll_s=0.5, initially_eligible=False): + market_id = f"polymarket_us:{guard.slug}" + report = {"market_id": market_id, "quantity": str(QUANTITY), "initial_bid": str(initial.best_bid), + "initial_ask": str(initial.best_ask), "stop_price": str(trigger), "child_price_bound": str(BOUND), + "maximum_entry_value_usd": str(BOUND * QUANTITY), + "trigger_mode": "initially eligible" if initially_eligible else "future crossing"} + request = OrderRequest(market_id=market_id, side="buy", quantity=QUANTITY, type="stop_market", stop_price=trigger, + time_in_force="gtd", expires_at=int((time.time() + wait_s) * 1000), client_order_id=f"us-stop-{uuid.uuid4().hex}") + engine = engine_for(adapter, directory) + parent_id = None + try: + await engine.start() + # This finite host drives market observations/timers itself, but must + # still renew the execution lease throughout the price wait. + engine._tasks.append(asyncio.create_task(engine._lease_loop())) + engine.market_master.put([MarketSpec(market_id=market_id, venue="polymarket_us", precision=rules.precision, + tradable=True, status="open")]) + parent = await engine.submit(request) + parent_id = parent.id + engine.set_book(market_id, initial) + # An execution check may arm an already eligible stop. It still + # persists waiting before the next actual venue book observation. + if not initially_eligible: + await engine.on_book({"market_id": market_id}) + assert engine.managed_order_controller.get(parent_id).state == "waiting" and not guard.sent + guard.stage = "entry" + deadline = time.monotonic() + wait_s + while engine.managed_order_controller.get(parent_id).state == "waiting" and time.monotonic() < deadline: + book = await book_for(adapter, guard.slug) + engine.set_book(market_id, book) + await engine.on_book({"market_id": market_id}) + await engine.managed_order_controller.on_timer() + if engine.managed_order_controller.get(parent_id).state != "waiting": + report["observed_trigger_ask"] = str(book.best_ask) + break + await asyncio.sleep(poll_s) + guard.stage = "blocked" + oid = guard.ids.get("entry") + if not oid: + if "entry" in guard.sent: + raise RuntimeError("create response unresolved; do not resend or infer acceptance") + await engine.cancel(parent_id) + managed = engine.managed_order_controller.get(parent_id) + report["outcome"] = ("triggered but local dispatch failed" if managed.state == "rejected" + else "no threshold crossing; no venue order") + report["parent_status"] = managed.as_order().status.value + report["detail"] = managed.detail + return report + entry = await terminal_order(adapter, oid, guard) + await engine.on_order(entry) + report.update(entry_status=entry.status.value, entry_filled=str(entry.filled), entry_average_price=str(entry.average_price), + entry_fee=str(entry.fee), parent_status=engine.managed_order_controller.get(parent_id).as_order().status.value) + if entry.filled <= 0: + report["outcome"] = "triggered without a fill" + return report + positions = [] + for _ in range(40): + positions = await positions_for(adapter, market_id) + if positions: + break + await asyncio.sleep(0.25) + owned = sum((p.contracts for p in positions), D("0")) + if owned != entry.filled or owned > QUANTITY: + raise RuntimeError("position does not equal the tiny test fill; do not close unrelated inventory") + guard.stage = "close" + try: + await adapter.close_position(market_id) + finally: + guard.stage = "blocked" + close_id = guard.ids.get("close") + if not close_id: + raise RuntimeError("close response unresolved; do not repeat it") + closed = await terminal_order(adapter, close_id, guard) + await engine.on_order(closed) + report.update(exit_status=closed.status.value, exit_filled=str(closed.filled), exit_average_price=str(closed.average_price), exit_fee=str(closed.fee)) + for _ in range(40): + positions = await positions_for(adapter, market_id) + if not positions: + break + await asyncio.sleep(0.25) + report["remaining_position_contracts"] = str(sum((p.contracts for p in positions), D("0"))) + report["outcome"] = "filled and closed" if not positions else "filled; close incomplete" + return report + finally: + guard.stage = "blocked" + try: + if parent_id and engine.managed_order_controller.get(parent_id) and engine.managed_order_controller.get(parent_id).state == "waiting": + await engine.cancel(parent_id) + for stage, oid in guard.ids.items(): + order = await terminal_order(adapter, oid, guard) + report[f"final_{stage}_status"] = order.status.value + report["creates_attempted"] = len(guard.sent) + finally: + await engine.stop() + (directory / "result.json").write_text(json.dumps(report, indent=2) + "\n") + + +async def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--credentials-dir", type=Path, required=True) + parser.add_argument("--execute", action="store_true") + args = parser.parse_args() + directory = Path(tempfile.mkdtemp(prefix="synpath-us-stop-")) + os.chmod(directory, 0o700) + async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client: + async with PolymarketUSTrading(credentials_from(args.credentials_dir), client=client) as adapter: + adapter.api.attempts = 1 + slug, rules, initial, trigger = await choose_market(adapter) + guard = Guard(slug, directory) + client.event_hooks["request"].append(guard.check) + client.event_hooks["response"].append(guard.observe) + preview = await adapter.preview_order(OrderRequest(market_id=f"polymarket_us:{slug}", side="buy", quantity=QUANTITY, + type="market", price=BOUND, time_in_force="ioc")) + print(json.dumps({"selected_market": slug, "quantity": str(QUANTITY), "initial_ask": str(initial.best_ask), + "stop_price": str(trigger), "maximum_entry_value_usd": str(QUANTITY * BOUND), + "preview_status": preview.status.value}), flush=True) + if args.execute: + print(json.dumps(await verify(adapter, guard, directory, rules, initial, trigger), indent=2)) + print(f"Private records: {directory}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/pyproject.toml b/pyproject.toml index dc3c13a..4807e56 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ classifiers = [ # `pip install synpath` is the whole install: the read API (httpx, pydantic), # order entry and streams (the venues' signing stacks and WebSockets), the # execution engine (aiosqlite for its journal) and the REST server (FastAPI, -# uvicorn). Only the Polymarket US exchange gRPC streams stay optional. +# uvicorn). PostgreSQL storage and Polymarket US exchange gRPC streams stay optional. dependencies = [ "httpx>=0.27", "pydantic>=2.6", @@ -35,6 +35,8 @@ dependencies = [ "PyJWT[crypto]>=2.8", "websockets>=13", "aiosqlite>=0.20", + "SQLAlchemy[asyncio]>=2.0,<3", + "alembic>=1.13,<2", "fastapi>=0.110", "uvicorn[standard]>=0.27", "tomli>=2.0; python_version < '3.11'", # tomllib is in the standard library from 3.11 @@ -48,6 +50,7 @@ synpath = "synpath.__main__:main" # `trading`, `ws`, `engine` and `server` are kept as empty extras so an # existing `pip install synpath[trading]` keeps working; everything they used # to add is in the base dependencies now. +postgres = ["psycopg[binary]>=3.2,<4"] trading = [] ws = [] engine = [] diff --git a/synpath/__init__.py b/synpath/__init__.py index cbd0392..40fa158 100644 --- a/synpath/__init__.py +++ b/synpath/__init__.py @@ -84,6 +84,7 @@ __version__ = "0.8.1" +# Every venue this library speaks, by id. exchanges: dict[str, type[Exchange]] = { "kalshi": Kalshi, "polymarket": Polymarket, @@ -96,7 +97,6 @@ "betfair": Betfair, "smarkets": Smarkets, } -"""Every venue this library speaks, by id.""" def exchange(venue: str, **kwargs) -> Exchange: @@ -119,9 +119,10 @@ def exchange(venue: str, **kwargs) -> Exchange: Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, TriggerSource, + Rfq, RfqQuote, RfqStatus, RfqCloseReason, QuoteStatus, Combo, ComboLeg, ComboMarket, AcceptQuote, MakerQuote, CredentialsMissing, DuplicateClientOrderId, InsufficientFunds, InvalidOrder, - MarketHalted, OrderNotFound, OrderRejected, PermissionDenied, - RateBudgetExceeded, RiskRejected, + MarketHalted, OrderNotFound, OrderRejected, RfqNotFound, PermissionDenied, + RateBudgetExceeded, RiskRejected, CancelUnconfirmed, SubmissionInDoubt, SubmissionPending, ) from .trading.credentials import load_credentials, require from .client import Client @@ -188,9 +189,10 @@ def __getattr__(name: str): "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", + "Rfq", "RfqQuote", "RfqStatus", "RfqCloseReason", "QuoteStatus", "Combo", "ComboLeg", "ComboMarket", "AcceptQuote", "MakerQuote", "CredentialsMissing", "DuplicateClientOrderId", "InsufficientFunds", "InvalidOrder", - "MarketHalted", "OrderNotFound", "OrderRejected", "PermissionDenied", - "RateBudgetExceeded", "RiskRejected", + "MarketHalted", "OrderNotFound", "OrderRejected", "RfqNotFound", "PermissionDenied", + "RateBudgetExceeded", "RiskRejected", "CancelUnconfirmed", "SubmissionInDoubt", "SubmissionPending", "load_credentials", "require", "Client", # live streams "Stream", "StreamStats", "StreamEvent", "BookEvent", "BookLevel", "QuoteEvent", "TradeEvent", "OrderEvent", diff --git a/synpath/_deprecated.py b/synpath/_deprecated.py index 428470f..627cae0 100644 --- a/synpath/_deprecated.py +++ b/synpath/_deprecated.py @@ -23,8 +23,8 @@ T = TypeVar("T") +# The rename every contract-count field and argument went through. QUANTITY = {"amount": "quantity"} -"""The rename every contract-count field and argument went through.""" def warn(old: str, new: str, *, stacklevel: int = 3) -> None: diff --git a/synpath/_native.py b/synpath/_native.py index 11c3424..3a0f7a6 100644 --- a/synpath/_native.py +++ b/synpath/_native.py @@ -23,8 +23,8 @@ except ImportError: # pragma: no cover - only without a built extension core = None +# True when the Rust core is in use. ENABLED = core is not None -"""True when the Rust core is in use.""" def pick(name: str, fallback: Any) -> Any: diff --git a/synpath/base.py b/synpath/base.py index 9c8ad1d..0000582 100644 --- a/synpath/base.py +++ b/synpath/base.py @@ -34,15 +34,24 @@ from . import ids from .types import BookSide, Candle, Event, FeeSchedule, Market, OrderBook, Series, Trade +# What a venue can do, in the shape ccxt's `has` uses. +# +# True — supported; the method's docstring says how the venue answers it +# "partial" — supported, but the result is thinner than the type suggests; +# the method's docstring says exactly how +# False — not available; calling raises `NotSupported` Capability = Literal[True, False, "partial"] -"""What a venue can do, in the shape ccxt's `has` uses. - - True — supported; the method's docstring says how the venue answers it - "partial" — supported, but the result is thinner than the type suggests; - the method's docstring says exactly how - False — not available; calling raises `NotSupported` -""" +# Every question `has` can be asked, for every venue. +# +# This list is the single place a capability is named. `Exchange.__init_subclass__` +# fills a venue's `has` from it, so a venue that says nothing about a capability +# gets `False` rather than a hole. `has[key]` therefore never raises KeyError on +# any venue — which matters, because the whole point of `has` is to be safe to +# read *before* you know whether something is supported. +# +# The read-only adapters answer `False` to every trading key; a trading +# adapter for the same venue subclasses one and says what it adds. CAPABILITIES: tuple[str, ...] = ( # -- read -- "fetch_markets", @@ -82,70 +91,61 @@ "fetch_queue_position", "fetch_fee_estimate", "rfq", + # RFQ and combos (docs/rfq-combo.md): the typed requester side, the + # typed quoter side, and multi-leg instruments. `rfq` above is the older + # key for the raw Kalshi RFQ methods, kept for one release. + "rfq_taker", + "rfq_quoter", + "combos", "split_merge", "watch_orders", "watch_my_trades", "watch_positions", "watch_balance", ) -"""Every question `has` can be asked, for every venue. - -This list is the single place a capability is named. `Exchange.__init_subclass__` -fills a venue's `has` from it, so a venue that says nothing about a capability -gets `False` rather than a hole. `has[key]` therefore never raises KeyError on -any venue — which matters, because the whole point of `has` is to be safe to -read *before* you know whether something is supported. - -The read-only adapters answer `False` to every trading key; a trading -adapter for the same venue subclasses one and says what it adds. -""" LEGAL_CAPABILITY_VALUES = frozenset({True, False, "partial"}) +# The status words every venue understands, with one meaning each. +# +# open — listed and trading +# closed — trading stopped, outcome not yet final +# settled — outcome final and paid +# all — no filter +# +# A venue that cannot filter by one of these raises `NotSupported` for it. What +# it must not do is accept the word and quietly answer a different question: +# `status="settled"` returning live markets on one venue and settled ones on +# another is the kind of disagreement a unified API exists to remove. MARKET_STATUSES: tuple[str, ...] = ("open", "closed", "settled", "all") -"""The status words every venue understands, with one meaning each. - - open — listed and trading - closed — trading stopped, outcome not yet final - settled — outcome final and paid - all — no filter - -A venue that cannot filter by one of these raises `NotSupported` for it. What -it must not do is accept the word and quietly answer a different question: -`status="settled"` returning live markets on one venue and settled ones on -another is the kind of disagreement a unified API exists to remove. -""" +# How a page of markets can be ordered. +# +# Polymarket orders at the venue. Kalshi and Polymarket US accept a sort +# parameter and ignore it, so on those the page is ordered here after it is +# read, the way ccxt and pmxt do it: `sort` then means "this page, ordered by", +# not "the first page of the whole catalog ordered by". Each adapter's +# `fetch_markets` docstring says which venue figure the key reads. MARKET_SORTS: tuple[str, ...] = ("volume", "liquidity", "newest") -"""How a page of markets can be ordered. - -Polymarket orders at the venue. Kalshi and Polymarket US accept a sort -parameter and ignore it, so on those the page is ordered here after it is -read, the way ccxt and pmxt do it: `sort` then means "this page, ordered by", -not "the first page of the whole catalog ordered by". Each adapter's -`fetch_markets` docstring says which venue figure the key reads. -""" +# Rows one catalog call can return, on every venue. +# +# Uniform rather than per-venue. Polymarket's keyset endpoint hard-caps at 100 no +# matter what is asked, so a higher ceiling elsewhere would mean `limit=500` +# returning 500 rows on one venue and 100 on another, with nothing saying it had +# been reduced. Ask for more than this and it is clamped; the cursor is how you +# get the rest. MAX_PAGE_LIMIT = 100 -"""Rows one catalog call can return, on every venue. - -Uniform rather than per-venue. Polymarket's keyset endpoint hard-caps at 100 no -matter what is asked, so a higher ceiling elsewhere would mean `limit=500` -returning 500 rows on one venue and 100 on another, with nothing saying it had -been reduced. Ask for more than this and it is clamped; the cursor is how you -get the rest. -""" +# Longest this library will sleep inside one call, in seconds. +# +# A venue's `Retry-After` is a hint, not an instruction. Honouring a large one +# literally would let the venue park a caller's thread for as long as it likes -- +# and in the server that thread belongs to a pool shared with every other +# request, so a handful of rate-limited calls would take the whole service down. +# The header's real value is still on the raised `RateLimitExceeded`, so a caller +# that wants to wait longer can decide that for itself. MAX_RETRY_WAIT = 10.0 -"""Longest this library will sleep inside one call, in seconds. - -A venue's `Retry-After` is a hint, not an instruction. Honouring a large one -literally would let the venue park a caller's thread for as long as it likes -- -and in the server that thread belongs to a pool shared with every other -request, so a handful of rate-limited calls would take the whole service down. -The header's real value is still on the raised `RateLimitExceeded`, so a caller -that wants to wait longer can decide that for itself. -""" class RateLimiter: @@ -276,9 +276,9 @@ def __init__( self.base_url = base_url.rstrip("/") self.limiter = limiter self.attempts = attempts + # Named at the front of every error this client raises, so a caller + # reads `kalshi: insufficient balance`, not a class name and a path. self.venue = venue - """Named at the front of every error this client raises, so a caller - reads `kalshi: insufficient balance`, not a class name and a path.""" self._client = client or httpx.Client(timeout=timeout, follow_redirects=True) def request(self, method: str, path: str, **kwargs: Any) -> Any: @@ -349,9 +349,9 @@ def __init__( self.base_url = base_url.rstrip("/") self.limiter = limiter self.attempts = attempts + # Named at the front of every error this client raises, so a caller + # reads `kalshi: insufficient balance`, not a class name and a path. self.venue = venue - """Named at the front of every error this client raises, so a caller - reads `kalshi: insufficient balance`, not a class name and a path.""" self._client = client or httpx.AsyncClient( timeout=timeout, follow_redirects=True, headers=headers, ) @@ -471,10 +471,14 @@ def _clean(params: Any) -> Any: def complete_capabilities(cls: type) -> None: - """Fill `cls.has` from `CAPABILITIES`, refusing typos and illegal values. + """Build a complete `cls.has` map for every name in `CAPABILITIES`. + + Validate the class's own declarations, rejecting unknown capability names + and illegal values. Inherit from the nearest ancestor in method resolution + order with a non-empty `has` dictionary, then apply the class's overrides. + Capabilities declared by neither default to `False`. - Shared by the read adapters and the trading adapters, so both answer - every question the same way. See `Exchange.__init_subclass__`. + Shared by read, trading and WebSocket adapters during subclass creation. """ declared = cls.__dict__.get("has", {}) if not isinstance(declared, dict): @@ -524,12 +528,11 @@ class Exchange(ABC): id: str name: str + # What this venue can do. Always complete: see `__init_subclass__`. + # + # A subclass declares only what differs from "no". Everything in + # `CAPABILITIES` it stays silent about is filled in as `False`. has: dict[str, Capability] = {} - """What this venue can do. Always complete: see `__init_subclass__`. - - A subclass declares only what differs from "no". Everything in - `CAPABILITIES` it stays silent about is filled in as `False`. - """ face_value: float = 1.0 book_model: str = "native_per_outcome" diff --git a/synpath/betfair.py b/synpath/betfair.py index e6b97ba..f6ac83e 100644 --- a/synpath/betfair.py +++ b/synpath/betfair.py @@ -76,6 +76,8 @@ VENUE = "betfair" +# Where an account logs in and calls the API, by the Betfair site it is +# registered on. An Italian account cannot log in on .com, and so on. JURISDICTIONS: dict[str, dict[str, str]] = { "com": {"identity": "identitysso.betfair.com", "cert": "identitysso-cert.betfair.com", "api": "api.betfair.com"}, "it": {"identity": "identitysso.betfair.it", "cert": "identitysso-cert.betfair.it", "api": "api.betfair.it"}, @@ -86,67 +88,65 @@ "com.au": {"identity": "identitysso.betfair.com.au", "cert": "identitysso-cert.betfair.com.au", "api": "api.betfair.com"}, } -"""Where an account logs in and calls the API, by the Betfair site it is -registered on. An Italian account cannot log in on .com, and so on.""" BETTING_PATH = "/exchange/betting/rest/v1.0" +# Betfair publishes no request rate for market data, only a weight per +# request; kept modest and shared process-wide. LIMITER = RateLimiter(5.0, burst=10) -"""Betfair publishes no request rate for market data, only a weight per -request; kept modest and shared process-wide.""" +# Seconds after which the session is extended with `keepAlive` before the +# next call. A session lasts 12 hours (24 on UK and Irish accounts) from its +# last keepAlive, and API calls do not extend it, so a long-running process +# has to. SESSION_REFRESH_AFTER = 4 * 3600.0 -"""Seconds after which the session is extended with `keepAlive` before the -next call. A session lasts 12 hours (24 on UK and Irish accounts) from its -last keepAlive, and API calls do not extend it, so a long-running process -has to.""" +# A request's projection weight times its number of markets may not pass +# this, or Betfair refuses the whole request with `TOO_MUCH_DATA`. MAX_WEIGHT = 200 -"""A request's projection weight times its number of markets may not pass -this, or Betfair refuses the whole request with `TOO_MUCH_DATA`.""" CATALOGUE_WEIGHTS: dict[str, int] = { "COMPETITION": 0, "EVENT": 0, "EVENT_TYPE": 0, "MARKET_START_TIME": 0, "RUNNER_DESCRIPTION": 0, "MARKET_DESCRIPTION": 1, "RUNNER_METADATA": 1, } BOOK_WEIGHTS: dict[str, int] = {"EX_BEST_OFFERS": 5, "EX_TRADED": 17, "EX_ALL_OFFERS": 17} +# Betfair's weights for the pairs it prices below their sum. BOOK_COMBINED_WEIGHTS: dict[frozenset[str], int] = { frozenset({"EX_BEST_OFFERS", "EX_TRADED"}): 20, frozenset({"EX_ALL_OFFERS", "EX_TRADED"}): 32, } -"""Betfair's weights for the pairs it prices below their sum.""" +# `listMarketBook` with no price projection still weighs 2 a market. BOOK_BASE_WEIGHT = 2 -"""`listMarketBook` with no price projection still weighs 2 a market.""" LISTING_PROJECTION = ("EVENT", "EVENT_TYPE", "COMPETITION", "MARKET_START_TIME", "MARKET_DESCRIPTION", "RUNNER_DESCRIPTION") LOOKUP_PROJECTION = LISTING_PROJECTION + ("RUNNER_METADATA",) +# The only price projection a delayed key serves in full: three levels a side. BOOK_PRICE_DATA = ("EX_BEST_OFFERS",) -"""The only price projection a delayed key serves in full: three levels a side.""" +# `listMarketCatalogue` returns at most this many markets, weight permitting. MAX_CATALOGUE_RESULTS = 1000 -"""`listMarketCatalogue` returns at most this many markets, weight permitting.""" +# Most catalogue requests one listing call makes while filling a page. MAX_PAGES = 50 -"""Most catalogue requests one listing call makes while filling a page.""" +# The Market Base Rate on most .com markets, used when a market's own +# `marketBaseRate` was not read. DEFAULT_COMMISSION = 0.05 -"""The Market Base Rate on most .com markets, used when a market's own -`marketBaseRate` was not read.""" +# Betfair sorts its catalogue by matched or available money; it has no +# listing time to sort by. SORTS = {"volume": "MAXIMUM_TRADED", "liquidity": "MAXIMUM_AVAILABLE"} -"""Betfair sorts its catalogue by matched or available money; it has no -listing time to sort by.""" SESSION_ERRORS = frozenset({"INVALID_SESSION_INFORMATION", "NO_SESSION"}) AUTH_ERRORS = SESSION_ERRORS | {"INVALID_APP_KEY", "NO_APP_KEY", "ACCESS_DENIED", "APP_KEY_CREATION_FAILED"} RATE_ERRORS = frozenset({"TOO_MANY_REQUESTS"}) +# Betfair's CLASSIC odds ladder: from each band's start to its end, in its step. CLASSIC_LADDER_BANDS: tuple[tuple[str, str, str], ...] = ( ("1.01", "2", "0.01"), ("2", "3", "0.02"), ("3", "4", "0.05"), ("4", "6", "0.1"), ("6", "10", "0.2"), ("10", "20", "0.5"), ("20", "30", "1"), ("30", "50", "2"), ("50", "100", "5"), ("100", "1000", "10"), ) -"""Betfair's CLASSIC odds ladder: from each band's start to its end, in its step.""" def _classic_ladder() -> tuple[Decimal, ...]: @@ -160,8 +160,8 @@ def _classic_ladder() -> tuple[Decimal, ...]: return tuple(ticks) +# Every price Betfair accepts on a CLASSIC market, 1.01 to 1000 (350 ticks). CLASSIC_LADDER: tuple[Decimal, ...] = _classic_ladder() -"""Every price Betfair accepts on a CLASSIC market, 1.01 to 1000 (350 ticks).""" _LADDER_SET = frozenset(CLASSIC_LADDER) @@ -247,9 +247,9 @@ def raw_levels(rows: Any) -> list[list[float]]: return [[row["price"], row["size"]] for row in rows or [] if row and row.get("price") is not None] +# A runner's final status as the YES result. A removed runner (a non-runner) +# has its bets voided. PLACED is the place leg of an each-way market. RESULTS: dict[str, str] = {"WINNER": "yes", "LOSER": "no", "PLACED": "yes", "REMOVED": "void"} -"""A runner's final status as the YES result. A removed runner (a non-runner) -has its bets voided. PLACED is the place leg of an each-way market.""" def status_of(market_status: str | None, runner_status: str | None) -> tuple[str, bool]: diff --git a/synpath/bucket.py b/synpath/bucket.py index 2cdcd62..20d921d 100644 --- a/synpath/bucket.py +++ b/synpath/bucket.py @@ -30,10 +30,14 @@ class BucketMember(_Base): + # A Synpath id, `venue:native`. market_id: str - """A Synpath id, `venue:native`.""" + # True when this member's YES is the opposite of the bucket's proposition. flip: bool = False - """True when this member's YES is the opposite of the bucket's proposition.""" + # False switches the member off: it stays in the definition but is not + # routed to, not in the merged bucket book, and not streamed for it. + # Definitions stored before this field existed load with every member on. + enabled: bool = True def book_side(self) -> BookSide: """Which of the member's two books is the bucket's YES book. Reading @@ -71,6 +75,21 @@ def market_id(self) -> str: def market_ids(self) -> list[str]: return [m.market_id for m in self.members] + def enabled_members(self) -> list[BucketMember]: + """The members switched on: the ones an order routes to and the merged book shows.""" + return [m for m in self.members if m.enabled] + + def routable(self) -> "Bucket": + """This bucket with only its enabled members: what the router, the + merged book and an order on the bucket work from. Raises `BadRequest` + when every member is switched off.""" + enabled = self.enabled_members() + if not enabled: + raise BadRequest(f"{self.market_id}: every member is switched off; enable at least one to route") + if len(enabled) == len(self.members): + return self + return self.model_copy(update={"members": enabled}) + def venues(self) -> set[str]: return {ids.venue_of(m.market_id) for m in self.members} @@ -83,9 +102,12 @@ def member(self, market_id: str) -> BucketMember: def check(self) -> None: """The shape checks a definition must pass before it is stored or traded: well-formed ids on known venues, no duplicates, at least two - members. Whether the members mean the same thing is not checked.""" + members, at least one of them enabled. Whether the members mean the + same thing is not checked.""" if len(self.members) < 2: raise BadRequest("a bucket needs at least two members") + if not any(m.enabled for m in self.members): + raise BadRequest("a bucket needs at least one enabled member") seen: set[str] = set() for m in self.members: ids.venue_of(m.market_id) # raises BadRequest on a bare or unknown id diff --git a/synpath/client.py b/synpath/client.py index 20f063a..4072a3e 100644 --- a/synpath/client.py +++ b/synpath/client.py @@ -250,6 +250,26 @@ async def fetch_order(self, order_id: str, *, market_id: str | None = None, venu return await self._remote.fetch_order(order_id, market_id=market_id, venue=venue) return await self.trading(_venue_of(market_id, venue)).fetch_order(order_id) + async def fetch_orders(self, *, scope: str = "all", market_id: str | None = None, venue: str | None = None, + since: int | None = None, limit: int | None = None, + cursor: str | None = None) -> Page[Order]: + """Orders in every state, newest first, one page at a time. + + Through a server (`server=`) this is the engine's own record: venue + orders and engine-held parents (stops waiting, triggered, finished), + each child carrying its parent's id in `parent_id`; `scope="open"` + keeps the live ones. Directly against a venue it is that venue's + order history, and needs `market_id` or `venue`.""" + if scope not in ("all", "open"): + raise BadRequest("scope is 'all' or 'open'") + if self._remote: + return await self._remote.fetch_orders(scope=scope, market_id=market_id, venue=venue, since=since, + limit=limit, cursor=cursor) + adapter = self.trading(_venue_of(market_id, venue)) + if scope == "open": + return Page(await adapter.fetch_open_orders(market_id=market_id), next_cursor=None) + return await adapter.fetch_orders(market_id=market_id, since=since, limit=limit, cursor=cursor) + async def fetch_open_orders(self, *, market_id: str | None = None, venue: str | None = None) -> list[Order]: """One market's, one venue's, or every connected venue's open orders.""" if self._remote: diff --git a/synpath/engine/README.md b/synpath/engine/README.md new file mode 100644 index 0000000..0fa7542 --- /dev/null +++ b/synpath/engine/README.md @@ -0,0 +1,243 @@ +# Execution engine + +`synpath.engine` owns user instructions, durable lifecycle state, risk checks, +venue dispatch and reconciliation. The terminal consumes the engine; it does +not maintain another authoritative order database. See the public +[engine guide](../../docs/engine.md) for examples and +[order lifecycles](orders/lifecycle/README.md) for type-specific behavior. + +## Storage configuration + +SQLite is the default. PostgreSQL requires the optional `synpath[postgres]` +driver. SQLAlchemy asynchronous connections serve both databases; Alembic +owns versioned schema and reviewed data migrations. + +```toml +storage_backend = "sqlite" +journal = "/absolute/persistent/path/synpath.db" +halt_on_exit = false +session_timezone = "Europe/London" +session_end = "23:59:59" +``` + +For PostgreSQL: + +```toml +storage_backend = "postgresql" +journal = "postgresql://synpath@localhost/synpath_orders" +storage_schema = "execution" +halt_on_exit = false +``` + +The equivalent Python configuration is +`EngineConfig(storage_backend="postgresql", journal_path=dsn, storage_schema="execution")`. +Use a password file or deployment secret mechanism; do not commit a credentialed +DSN. Sidecar display/registry metadata uses `postgresql:`. The sidecar's +control/authentication database remains separate from execution storage. + +Select one backend and one active execution worker. PostgreSQL does not enable +multiple workers. SQLite uses WAL, full synchronous durability and foreign-key +enforcement; PostgreSQL requests synchronous commits and uses database-clock +execution leases. Operators must retain PostgreSQL WAL/fsync durability settings. + +## Application owner + +Set `SYNPATH_USER_ID`, or pass `EngineConfig(user_id=...)`, to a stable nonempty +identity. The default is `local-user`. Migration seeds `users.user_id` once; a +unique singleton column permits one user row. Restart with another configured +identity fails. An injected journal must match its engine's identity. + +All four instruction tables and submission reservations reference that user. +Requests cannot override the engine's owner; children inherit their parent's +owner. This is ownership enforcement, not customer authentication. Future +multi-user support requires a migration of the singleton restriction and +separate execution/authentication design. + +## Schema and migration compatibility + +The current logical version is 11. Alembic revision `0001_journal_v6` adopts the +frozen pre-Alembic baseline; `0002_single_user` adds the owner and foreign keys; +`0003_bucket_member_enabled` adds `bucket_members.enabled` (existing members stay on); +`0004_orders_recent` indexes `orders` newest first, overall and per market, and +gives any row without a `created_ts` its `updated_ts`; `0005_drop_fair_values` drops +the unused `fair_values` table. `0006_routed_execution_scope` adds indexed +execution-account membership for routed parents and removes their nominal venue +from snapshots and readable projections. See the upgrade preflight below. +See the [migration entry point](migrations/README.md). Historical SQLite +conversions prepare old files for the baseline; new changes use new revisions. + +Startup runs migrations through the selected SQLAlchemy connection under the +write/schema lock and transaction. Unknown schema or revision versions fail. +New migrations are reviewed explicitly. Current SQLAlchemy metadata describes +ownership/history objects; unrestricted autogeneration against it would not +represent the entire journal schema and must not be used. + +Existing instructions must belong to the configured user. Unknown or conflicting +instruction ownership stops migration for explicit review. Never relabel old +instructions silently. Unattributed venue observations with no original request +are preserved in `historical_order_records`, with their original identity and +snapshot still available through `typed_order`. + +Back up and verify restore before upgrading an existing store. The owner migration refuses downgrade; later revision downgrades may remove +fields or recreate dropped tables empty. Rollback requires compatible code and +a verified schema/data backup. Migrations are application-managed because they require +owner selection and locking; standalone/offline Alembic without that context is +refused. Selecting PostgreSQL creates a new store. Importing an existing SQLite +journal, including armed orders, is a separate operation and is not automatic. + +## Instruction tables and dispatch + +`market_orders`, `limit_orders`, `stop_market_orders`, and `stop_limit_orders` +are separate authoritative records. They retain ownership, account, client key, +original request, type-specific fields, lifecycle state and a versioned snapshot. +`orders` is the shared execution projection for reconciliation, ledger and API +reads. Other advanced types retain their existing managed-order persistence. +It holds venue orders and engine-held parents alike, so `orders_page` lists +every state newest first by `(created_ts, venue, id)`, a unique key that pages +without repeats or gaps (`GET /trading/orders?scope=all`); a child's +`parent_id` groups it under its parent. + +A type record, its event and pending action commit together before a venue call. +Venue responses update execution state while preserving the original instruction. +Submission keys are reserved globally within this single-user store. A same-key +retry returns the existing result; a different instruction is a conflict. DAY +expiry is resolved once to an absolute timestamp, preventing restart/retry from +extending its life. + +A stop crossing commits the irreversible parent transition, stable pending child +request and planned intent in one transaction. Sending happens afterwards. An +accepted child is attached before pending state is cleared. A restart may dispatch +an unsent planned action; a sending/unknown action must be reconciled first and +must never be blindly resent. Venue history pagination must complete. Venues +without echoed client keys require a durable native identity for adoption. + +The background dispatcher keeps venue latency off price observations. Its queue +is a hint; losing it cannot lose committed work. Parent hooks serialize price, +expiry, cancellation and child updates. Cancellation of uncertain children remains +pending until venue status is known. + +Only the venue confirms a cancellation. Cancelling a finished order, or an +engine-held parent no longer running because it finished before a restart, +returns its record without a venue call. A venue that answers "not found" to a +cancel is asked for the order itself and its verdict is recorded with the +filled quantity: `closed` if it filled, `canceled` only if the venue says so. +A venue with no record at all raises `CancelUnconfirmed` and the last observed +status stands; a parent pulling such a child stays `pending_cancel` and retries. +A parent whose children turn out to have filled everything ends `closed`. + +A send that gets no verdict raises `SubmissionInDoubt` with its +`client_order_id`; its intent stays `sending`. A retry under that id +reconciles with the venue first: it returns the adopted order, or raises +`SubmissionPending` while the venue has no answer yet, and never sends twice. Normal shutdown preserves orders; operator +halt/cancellation is explicit. Stops cannot observe prices while the worker is down. + +Stop-market children use venue legal bounds and IOC, recording unfilled quantity. +Stop-limit children retain their requested price and inherit the parent's TIF +and expiry (one TIF for the whole order; DAY goes as GTD at the session end). +A stop reads the book the engine already holds when it is created and when its +market is subscribed, not only on the next change. Precision +comes from registered market rules, with conservative venue defaults otherwise. +Risk checks still apply at the worst submitted price. Opening NO notional uses +`1 - YES price`: a contract is priced as its outcome (`outcome`), bought or sold. Fees are separate. + +All displayed prices/averages use the YES leg. Kalshi read projections may report +NO outcome costs alongside `side=yes`; execution averages use `outcome_side` +first to normalize them. Create averages already use YES. `Order.cost` retains +native outcome notional and need not represent YES sale proceeds. + +## Adding or consolidating order types + +Define and document fields, states, observations, actions, cancel/expiry semantics, +ownership and recovery before implementation. Register validation and processing; +add explicit Alembic schema/data revisions where needed. Shared machinery owns +retry identity, transactions, risk and reconciliation. A new type may use its own +table or a compatible existing table after an explicit design decision; a merge +must preserve instructions, identities, indexes, state and recovery semantics. + +Add a small lifecycle document beside the implementation and link this README for +shared rules. Verify fresh stores, migration, both backends, retries, failures, +cancellation, expiry, restart, process crashes and mocked adapter payloads. + +## Verification and remaining venue limits + +Offline tests use paper/scripted venues and mocked HTTP. Enable database tests +only against a disposable PostgreSQL database; each test creates/drops its own +schema: + +```sh +SYNPATH_TEST_POSTGRES_DSN=postgresql://localhost/synpath_test \ + python -m pytest tests/test_postgres_storage.py tests/test_owner_migrations.py +``` + +Live/demo suites are excluded by default. Real-money checks require explicit, +bounded operator authorization, a specific credential source, guarded writes and +matched cleanup. Private credentials, account records and receipts stay private. +Verification counts, SDK revisions and sanitized results belong in the relevant PR. + +Bounded live checks established a PostgreSQL Kalshi stop-limit crossing/fill and +reduce-only exit, plus a Polymarket US eligible stop-market fill and native close, +with terminal replay and no residual test position or orders. These do not prove +all venue guarantees. International Polymarket eligibility remains blocked on the +previous test host. Polymarket/US do not echo client keys; automatic adoption after +response loss needs persisted native identities. US ordinary orders lack general +reduce-only support. Its already-eligible test is not evidence of a future crossing. + + +## Cross-venue parent identity and upgrade preflight + +A bucket order is owned by the engine's stable `user_id`, with `venue=null` and +`account=null`. `execution_accounts` captures each enabled venue's exact Account; +venue children keep their concrete venue/account. A single captured bucket/account +scope is authorized, risk-checked and saved before routing; later bucket edits do +not alter it. One transaction commits the snapshot, account membership, readable +projection, submission result and events. The private `__managed__` storage key +must never be configured as a venue. Normal venue orders retain their concrete +identity. Without an explicit account mapping, the engine uses an adapter's +reported Account when available, otherwise the venue's default Account. + +Bucket requests cannot name a singular account or venue. Configure per-venue +accounts instead. Admission requires full-quantity capacity at every eligible +member (including correctly flipped outcome/price), then every actual child +passes current risk checks again. A valid split can therefore be refused when +no individual destination has capacity for the whole quantity. This reserves +neither funds nor capacity. Existing parent and venue states are unchanged. + +Viewing a complete parent/report or its audit history requires visibility of all +captured accounts; acting on it requires trade permission on all of them. Child +records retain their individual account access. `/journal`, `/order-log`, live +events and replay use the same parent-scope rules. Caller-provided request params +never establish audit visibility. Old order-list cursors must be discarded; the +new opaque cursor has version 2. Parent lookup works after the order finishes +and the process restarts; venue/account filters use captured membership. + +Back up and verify restore before upgrading. Migration preserves user, parent, +client and child IDs, status and fills, and records old nominal attribution as +`legacy_execution_identity`. It infers historical accounts only from the saved +parent account and persisted child orders/intents. When an enabled venue has no +unambiguous historical account evidence, startup refuses the migration and names +the affected parent/venue. Do not replace it with today's configured account. + +For a reviewed historical mapping, while the old engine is stopped, store a JSON +object under `meta.key='legacy_execution_accounts'`: parent ID -> venue -> complete +Account object (`venue`, `name`, optional `subaccount`). For example, an explicitly +reviewed mapping has shape `{"mo-example":{"polymarket":{"venue":"polymarket", +"name":"historical-desk","subaccount":null}}}`. This is an operator decision, +not an automatically generated guess. Insert/update that meta row in the existing +backend with bound values, retain the review evidence, then start the new engine +through its normal locked migration path. Conflicting historical evidence still +fails; an explicit mapping cannot silently override a persisted account. Missing +and conflicting cases roll back and can be retried after review on both backends. + +A legacy client ID without a verifiable submission reservation is refused rather +than submitted again; retrieve its existing parent by ID. New bucket client-ID +retries use the original captured scope even after a bucket edit. A mismatched +configured/adapter account blocks affected submissions, early uncertain-intent +recovery, child refresh, polling, reconciliation and cancellation. The original +activity stays unresolved until the original execution identity is available; +absence in a replacement account cannot establish that an order is lost. + +Do not run older code on the migrated store: restore a compatible backup, with +outstanding venue activity reviewed first. Do not run both engines concurrently. +This upgrade does not resolve the broader uncertain-send/resync limitations in +[the routed lifecycle](orders/lifecycle/routed.md). All PRE-6 verification uses +offline scripted venues; this documentation does not authorize actual trades. diff --git a/synpath/engine/__init__.py b/synpath/engine/__init__.py index fd7dd36..96dd374 100644 --- a/synpath/engine/__init__.py +++ b/synpath/engine/__init__.py @@ -20,18 +20,19 @@ from .engine import Engine, EngineConfig, Recovery from .events import EngineEvent, EventBus, Subscription -from .fair_values import FairValue, FairValues from .journal import Intent, IntentState, Journal, JournalEvent, LeaseLost +from .storage import JournalStore, create_journal from .ledger import Ledger, PositionState, Rollup -from .orders import ManagedOrder, ManagedOrders +from .orders import ManagedOrder, ManagedOrderController from .risk import Decision, KillSwitch, RiskConfig, RiskEngine +from .rfq import RfqDesk __all__ = [ "Engine", "EngineConfig", "Recovery", - "Journal", "JournalEvent", "Intent", "IntentState", "LeaseLost", + "JournalStore", "create_journal", "Journal", "JournalEvent", "Intent", "IntentState", "LeaseLost", "EventBus", "EngineEvent", "Subscription", "Ledger", "PositionState", "Rollup", "RiskConfig", "RiskEngine", "Decision", "KillSwitch", - "FairValues", "FairValue", - "ManagedOrder", "ManagedOrders", + "ManagedOrder", "ManagedOrderController", + "RfqDesk", ] diff --git a/synpath/engine/__main__.py b/synpath/engine/__main__.py index caebc9e..3bfd9ac 100644 --- a/synpath/engine/__main__.py +++ b/synpath/engine/__main__.py @@ -14,9 +14,9 @@ same journal exits with a message naming the one that holds it, rather than quietly trading the same account twice. -**Signals stop it properly.** `SIGINT` and `SIGTERM` apply the configured -halt policy first (by default, cancel everything resting through each -venue's own cancel-all), then release the lease and close the journal. Kill +**Signals stop it properly.** `SIGINT` and `SIGTERM` preserve order state, +release the lease and close the journal. `halt_on_exit=true` explicitly +applies the configured halt policy before shutdown. Kill it with `SIGKILL` and the journal still has every intent, which is what the recovery path is for. @@ -126,6 +126,9 @@ async def run(args: argparse.Namespace) -> int: format="%(asctime)s %(levelname)s %(message)s") engine_config = EngineConfig( journal_path=args.journal or config.get("journal", "synpath.db"), + storage_backend=config.get("storage_backend", "sqlite"), + session_timezone=config.get("session_timezone", "Europe/London"), + session_end=config.get("session_end", "23:59:59"), poll_interval_s=float(config.get("poll_interval_s", 5)), reconcile_interval_s=float(config.get("reconcile_interval_s", 60)), sweep_interval_s=float(config.get("sweep_interval_s", 10)), @@ -182,7 +185,7 @@ def _signal(name: str) -> None: failure = next((t for t in done if t is not waiter and t.exception()), None) if failure is not None: log.error("synpath.engine: %s stopped the engine: %s", failure.get_name(), failure.exception()) - if config.get("halt_on_exit", True): + if config.get("halt_on_exit", False): await engine.halt("the engine is shutting down", policy=engine_config.halt_policy) if feeds is not None: await feeds.close() @@ -232,12 +235,12 @@ async def _control_loop(engine: Engine, stopping: asyncio.Event) -> None: async def _status_loop(engine: Engine, interval: float) -> None: while engine.running: await asyncio.sleep(interval) - marks = engine.fair_values.marks() + marks = engine.marks() total = engine.ledger.total(marks) log.info( "synpath.engine: %s open orders, %s positions, realized %s, fees %s, events %s, halted=%s", len(engine.open_orders()), len(engine.ledger.open_positions()), total.realized, total.fees, - engine.bus.published, engine.risk.kill.engaged, + engine.bus.published, engine.order_risk_checks.kill.engaged, ) @@ -246,8 +249,7 @@ async def status(args: argparse.Namespace) -> int: journal = Journal(args.journal) await journal.open() try: - async with journal._db.execute("SELECT name, owner, expires_ts FROM leases") as cursor: - leases = [dict(row) async for row in cursor] + leases = await journal.lease_records() open_orders = await journal.open_orders() in_doubt = await journal.in_doubt() fills = await journal.fills() diff --git a/synpath/engine/alerts.py b/synpath/engine/alerts.py index 072bc78..5db3c8d 100644 --- a/synpath/engine/alerts.py +++ b/synpath/engine/alerts.py @@ -17,6 +17,7 @@ * **the daily loss is approaching its limit** — at the warning fraction, while there is still time to decide; * **a stream went quiet or reconnected with reconciliation required**; +* **a venue reported the other side of an RFQ trade** (`rfq.side_mismatch`); * **a venue rejected orders repeatedly** — the count in a window, not each one, because a rejection storm is one problem, not fifty. @@ -58,15 +59,15 @@ def __str__(self) -> str: class AlertRules: """When to speak up.""" + # Venue rejections within the window before one alert is raised. reject_burst: int = 10 - """Venue rejections within the window before one alert is raised.""" reject_window_s: float = 60.0 + # Fraction of the daily loss limit that warrants a warning. daily_loss_warning: Decimal = Decimal("0.8") - """Fraction of the daily loss limit that warrants a warning.""" + # The same alert is not repeated inside this window. repeat_after_s: float = 300.0 - """The same alert is not repeated inside this window.""" + # Alert kinds to suppress entirely. quiet_kinds: tuple[str, ...] = () - """Alert kinds to suppress entirely.""" class Alerts: @@ -98,6 +99,11 @@ def _on_event(self, event: EngineEvent) -> None: f"{payload.get('reason', 'unknown')}; it may be live and unmanaged", payload) elif kind == "intent.swept": self.raise_alert("info", "swept", f"an unanswered order was swept: {payload.get('client_order_id')}", payload) + elif kind == "rfq.side_mismatch": + self.raise_alert("critical", "rfq_side", + f"{payload.get('venue')} reports the {payload.get('reported')} side on quote " + f"{payload.get('quote_id')}, the engine asked for {payload.get('expected')}; " + "the venue's side was booked", payload) elif kind == "engine.halted": self.raise_alert("critical", "halt", f"trading halted ({payload.get('policy')}): {payload.get('reason')}", payload) elif kind == "reconcile.orphan": diff --git a/synpath/engine/combo_board.py b/synpath/engine/combo_board.py new file mode 100644 index 0000000..59d3dfe --- /dev/null +++ b/synpath/engine/combo_board.py @@ -0,0 +1,1149 @@ +"""The combo board: a venue's combo-eligible markets as games and questions. + +A combo venue lists the markets a combo may take as legs, but not in a shape +a person can pick from: Polymarket's catalogue is a flat list by volume, +Polymarket US nests markets in events, and Kalshi lists eligibility by +collection (events only). `board_of(...)` turns those rows into what a combo +builder draws (polymarket.com's "Combos", Kalshi's combo builder): + +* a **game**: an event with two (or three, with a draw) participants and, per + participant, a moneyline, a spread and a total pick where the venue lists + that market type. A pick is one market and the outcome that backs it: a + Polymarket moneyline "Patriots vs. Bears" is YES for the Patriots and NO + for the Bears; a total is YES for the over and NO for the under; Kalshi's + game event has one YES market per team; +* a **question**: any other eligible market, with a YES and a NO pick; +* **categories**: leagues (NFL, NBA, Soccer...) and other kinds (Crypto, + Politics), each with the number of events on the board. + +Everything is read from the venue's own fields, never from URL slugs or +title wording: market types (Polymarket's Gamma `sportsMarketType`, Polymarket +US's `marketType`, Kalshi's event `product_metadata.competition_scope`), +participants (Gamma's and the US gateway's event `teams` with their +abbreviations, the US `marketSides[].team`, Kalshi's `yes_sub_title`, +`custom_strike` and event `sub_title` codes), lines (Gamma's `line`, Kalshi's +`floor_strike`), tags and series. A participant with no venue abbreviation +shows its name. What cannot be placed in a game is a question, so a market +is never dropped for not fitting. Prices on +a pick are the catalogue's (`yes_bid`, `yes_ask`; `price` is what the pick's +outcome costs now: the YES ask, or 1 - the YES bid), for a caller to replace +with live books where it has them. +""" +from __future__ import annotations + +import asyncio +import gc +import inspect +import re +import time +from decimal import Decimal +from typing import Any, Awaitable, Callable, Iterable, Literal + +from pydantic import Field + +from ..types import Event, Market, _Base + +MarketKind = Literal["moneyline", "spread", "total"] +KINDS: tuple[MarketKind, ...] = ("moneyline", "spread", "total") + +# League and category, from a venue's tags or a Kalshi series ticker. The first +# match wins, so more specific names come first. +LEAGUES: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + # key, label, words (tag slugs, tag labels, Kalshi series prefixes after "KX") + ("cfb", "College Football", ("cfb", "ncaaf", "college-football", "college football")), + ("nfl", "NFL", ("nfl",)), + ("cbb", "College Basketball", ("cbb", "ncaab", "college-basketball", "college basketball")), + ("nba", "NBA", ("nba",)), + ("wnba", "WNBA", ("wnba",)), + ("nhl", "NHL", ("nhl",)), + ("mlb", "MLB", ("mlb",)), + ("tennis", "Tennis", ("atp", "wta", "tennis")), + ("soccer", "Soccer", ("soccer", "epl", "ucl", "uel", "lal", "laliga", "la-liga", "bundesliga", "seriea", + "serie-a", "ligue-1", "mls", "uefa", "fifa", "premier-league", "champions-league", + "europa-league")), + ("ufc", "UFC", ("ufc", "mma")), + ("golf", "Golf", ("golf", "pga")), + ("esports", "Esports", ("esports", "lol", "csgo", "cs2", "dota", "valorant")), + ("crypto", "Crypto", ("crypto", "btc", "bitcoin", "eth", "ethereum", "sol", "solana", "xrp", "doge")), + ("politics", "Politics", ("politics", "elections", "election", "trump", "midterms", "congress")), + ("economics", "Economics", ("economics", "economy", "fed", "cpi", "inflation", "gdp", "jobs")), +) +SPORTS = frozenset({"cfb", "nfl", "cbb", "nba", "wnba", "nhl", "mlb", "tennis", "soccer", "ufc", "golf", "esports"}) +LABELS = {key: label for key, label, _ in LEAGUES} +OTHER = ("other", "Other") + +# A question event (a price bracket, a field of candidates) shows at most this +# many markets: those priced nearest even, where a choice is live. +QUESTIONS_PER_EVENT = 3 + + + +class BoardPick(_Base): + """One price button: a market and the outcome that backs the pick.""" + + market_id: str + outcome: Literal["yes", "no"] + label: str = Field(description="What the button says: `CHI`, `CHI -2.5`, `O 41.5`, `YES`") + kind: str = Field(description="`moneyline`, `spread`, `total` or `question`") + line: str | None = None + title: str | None = Field(default=None, description="The market's own title") + yes_bid: Decimal | None = None + yes_ask: Decimal | None = None + price: Decimal | None = Field(default=None, description="What one contract of this pick costs now " + "(catalogue): YES ask, or 1 - YES bid") + + +class BoardRow(_Base): + """One participant of a game and its picks by market type.""" + + name: str + abbr: str | None = None + alias: str | None = Field(default=None, description="The venue's short name for the participant, where it has " + "one (Gamma's team `alias`: `Al Fayha`)") + picks: dict[str, BoardPick | None] = Field(default_factory=dict, description="By `moneyline`, `spread`, `total`") + + +class BoardEvent(_Base): + """A game (participants x market types) or a question (YES / NO).""" + + id: str + venue: str + kind: Literal["game", "question"] + title: str + category: str = Field(description="The category key: a league (`nfl`) or a kind (`crypto`), else `other`") + category_label: str + group: Literal["sports", "other"] + start_time: int | None = Field(default=None, description="Game start or market close, ms") + volume: Decimal | None = None + market_types: list[str] = Field(default_factory=list, description="The game's market types, in board order") + rows: list[BoardRow] = Field(default_factory=list, description="A game's participants") + yes: BoardPick | None = Field(default=None, description="A question's YES pick") + no: BoardPick | None = Field(default=None, description="A question's NO pick") + market_ids: list[str] = Field(default_factory=list, description="Every market behind the event's picks") + + +class BoardCategory(_Base): + key: str + label: str + group: Literal["sports", "other"] + count: int = Field(description="Events on the board in this category") + + +BoardStatus = Literal["building", "ready"] + + +class ComboBoard(_Base): + """A venue's combo-eligible markets, grouped for a builder: the whole + board, or one page of one category of it (`category`, `next_cursor`).""" + + venue: str + count: int = Field(description="Combo-eligible markets read") + events: list[BoardEvent] = Field(default_factory=list) + categories: list[BoardCategory] = Field(default_factory=list, description="By count, sports first") + complete: bool = Field(default=True, description="Every page of the venue's list was read") + as_of: int | None = None + status: BoardStatus = Field(default="ready", description="`building` while serve's first build of this venue's " + "board runs (no events yet), else `ready`") + built_at: int | None = Field(default=None, description="When the board served was built, ms") + category: str | None = Field(default=None, description="A page: the category it is of (`all` for every one)") + total: int | None = Field(default=None, description="A page: the events in its category (and search)") + next_cursor: str | None = Field(default=None, description="A page: the cursor of the next one; null at the end") + error: str | None = Field(default=None, description="Why serve's last build of this board failed, while the " + "board served is an older one (or none yet)") + + +class ComboBoardCategories(_Base): + """A board's rail: its categories with counts, without the events.""" + + venue: str + status: BoardStatus = "ready" + built_at: int | None = None + complete: bool = True + count: int = Field(default=0, description="Combo-eligible markets read") + events: int = Field(default=0, description="Events on the board") + categories: list[BoardCategory] = Field(default_factory=list, description="By count, sports first") + error: str | None = None + + +# --------------------------------------------------------------------------- +# Building a board +# --------------------------------------------------------------------------- + +class Side(_Base): + """A game's participant as the venue names it: a team, a player, or the draw.""" + + key: str = Field(description="The participant's identity within the event: the venue's team id where it has " + "one, else its name") + name: str + abbr: str | None = Field(default=None, description="The venue's own abbreviation, where it has one") + alias: str | None = Field(default=None, description="The venue's own short name, where it has one") + draw: bool = False + + +class BoardMarket(_Base): + """One eligible market, in the terms the board needs, whatever the venue. + Every field comes from the venue's own fields (`*_markets` below).""" + + market_id: str + title: str + event_key: str = Field(description="Markets of one game share this") + event_title: str | None = None + kind: str | None = Field(default=None, description="`moneyline`, `spread`, `total` from the venue's market " + "type, else None (a question)") + outcomes: list[str] = Field(default_factory=list, description="The venue's outcome names, YES first") + label: str | None = Field(default=None, description="The market's own short label (Kalshi `yes_sub_title`, " + "Polymarket `groupItemTitle`)") + participants: list[Side] = Field(default_factory=list, description="The event's participants, in the " + "venue's order") + yes_side: Side | None = Field(default=None, description="Who the YES outcome backs") + no_side: Side | None = Field(default=None, description="Who the NO outcome backs (a two-sided market)") + line: Decimal | None = Field(default=None, description="A spread's line for the YES side (-2.5: it gives " + "2.5), a total's line") + tags: list[str] = Field(default_factory=list) + series: str | None = None + yes_bid: Decimal | None = None + yes_ask: Decimal | None = None + start_time: int | None = None + volume: Decimal | None = None + + +# The builders below run in worker threads beside serve's event loop +# (`board_for`). Under the GIL a thread building the whole catalogue starves +# the loop for seconds: health checks and order routes waited 1-6 s. Sleeping +# 1 ms every `LOCK_SHARE_EVERY` items hands the loop the lock on a fixed beat, +# at the cost of a slower background build. +LOCK_SHARE_EVERY = 100 +LOCK_SHARE_S = 0.001 + + +def share_the_lock(items_done: int) -> None: + if items_done % LOCK_SHARE_EVERY == 0: + time.sleep(LOCK_SHARE_S) + + +def board_of(venue: str, markets: Iterable[BoardMarket], *, complete: bool = True, + as_of: int | None = None) -> ComboBoard: + """Games and questions from a venue's eligible markets (already + normalised as `BoardMarket`s: `polymarket_markets`, `polymarket_us_markets`, + `kalshi_markets`).""" + rows = [m for m in markets if m.market_id] + by_event: dict[str, list[BoardMarket]] = {} + for market in rows: + by_event.setdefault(market.event_key, []).append(market) + events: list[BoardEvent] = [] + for events_done, (key, members) in enumerate(by_event.items(), 1): + share_the_lock(events_done) + game = game_of(venue, key, members) + if game is not None: + events.append(game) + # A game's other lines (spreads and totals not nearest even) stay off the board. + members = [m for m in members if m.kind not in KINDS] + events.extend(questions_of(venue, members)) + events.sort(key=lambda e: (e.group != "sports", e.category_label, e.start_time or 2**62, e.title)) + return ComboBoard(venue=venue, count=len(rows), events=events, categories=categories_of(events), + complete=complete, as_of=as_of) + + +def categories_of(events: list[BoardEvent]) -> list[BoardCategory]: + counts: dict[str, BoardCategory] = {} + for event in events: + held = counts.get(event.category) + if held is None: + counts[event.category] = BoardCategory(key=event.category, label=event.category_label, + group=event.group, count=1) + else: + held.count += 1 + return sorted(counts.values(), key=lambda c: (c.group != "sports", -c.count, c.label)) + + +def game_of(venue: str, key: str, members: list[BoardMarket]) -> BoardEvent | None: + """The game these markets make, if any is a moneyline with participants.""" + typed = [m for m in members if m.kind in KINDS] + moneylines = [m for m in typed if m.kind == "moneyline"] + if not moneylines: + return None + sides = participants(typed) + if len([s for s in sides if not s.draw]) < 2: + return None + first = members[0] + category, label, group = category_of([t for m in members for t in m.tags], first.series) + rows = [BoardRow(name=s.name, abbr=s.abbr, alias=s.alias) for s in sides] + used: list[str] = [] + for kind in KINDS: + for row, pick in zip(rows, picks_for(kind, [m for m in typed if m.kind == kind], sides)): + row.picks[kind] = pick + if pick is not None and pick.market_id not in used: + used.append(pick.market_id) + kinds = [k for k in KINDS if any(r.picks.get(k) for r in rows)] + return BoardEvent( + id=key, venue=venue, kind="game", title=first.event_title or " vs. ".join(s.name for s in sides), + category=category, category_label=label, group=group, + start_time=min((m.start_time for m in members if m.start_time), default=None), + volume=sum((m.volume for m in members if m.volume is not None), Decimal(0)) or None, + market_types=kinds, rows=rows, market_ids=used) + + +def questions_of(venue: str, members: list[BoardMarket]) -> list[BoardEvent]: + """YES / NO cards: at most `QUESTIONS_PER_EVENT` of an event's markets, + the nearest even first. A market whose outcomes are named (`Cavaliers` / + `76ers`, `Odd` / `Even`) says them on its buttons.""" + def evenness(m: BoardMarket) -> Decimal: + price = mid(m) + return abs(price - Decimal("0.5")) if price is not None else Decimal(1) + + out = [] + for market in sorted(members, key=evenness)[:QUESTIONS_PER_EVENT]: + category, label, group = category_of(market.tags, market.series) + title = market.title + if market.label and market.label.lower() not in title.lower(): + title = f"{title}: {market.label}" + named = len(market.outcomes) == 2 and not yes_no(market.outcomes) + yes_label, no_label = (market.outcomes[0], market.outcomes[1]) if named else ("YES", "NO") + out.append(BoardEvent( + id=market.market_id, venue=venue, kind="question", title=title, category=category, + category_label=label, group=group, start_time=market.start_time, volume=market.volume, + yes=pick_of(market, "yes", yes_label, "question"), no=pick_of(market, "no", no_label, "question"), + market_ids=[market.market_id])) + return out + + +def participants(typed: list[BoardMarket]) -> list[Side]: + """A game's participants, in the venue's order: the event's own list + (Polymarket's and Polymarket US's teams, Kalshi's title and codes) where + a moneyline backs them, else the moneylines' sides; a draw last.""" + moneylines = [m for m in typed if m.kind == "moneyline"] + backed: dict[str, Side] = {} + for market in moneylines: + for side in (market.yes_side, market.no_side): + if side is not None: + backed.setdefault(side.key, side) + listed = next((m.participants for m in typed if m.participants), []) + out = [s for s in listed if s.key in backed and not s.draw] + out += [s for key, s in backed.items() if not s.draw and key not in {o.key for o in out}] + return out + [s for s in backed.values() if s.draw][:1] + + +def picks_for(kind: str, markets: list[BoardMarket], sides: list[Side]) -> list[BoardPick | None]: + """One pick per participant for this market type (None where the venue has none).""" + out: list[BoardPick | None] = [None] * len(sides) + if not markets: + return out + if kind == "total": + market = nearest_even(markets) + over, under = (market.outcomes[0], market.outcomes[1]) if len(market.outcomes) == 2 \ + and not yes_no(market.outcomes) else ("Over", "Under") + text = f" {format_line(abs(market.line))}" if market.line is not None else "" + out[0] = pick_of(market, "yes", f"{short(over)}{text}", kind) + if len(sides) > 1: + out[1] = pick_of(market, "no", f"{short(under)}{text}", kind) + return out + index = {s.key: i for i, s in enumerate(sides)} + two_sided = [m for m in markets if m.yes_side is not None and m.no_side is not None + and m.yes_side.key in index and m.no_side.key in index] + if two_sided: + market = nearest_even(two_sided) + for outcome, side in (("yes", market.yes_side), ("no", market.no_side)): + line = market.line if outcome == "yes" else (-market.line if market.line is not None else None) + at = index[side.key] # type: ignore[union-attr] + out[at] = pick_of(market, outcome, button_label(kind, sides[at], line), kind) # type: ignore[arg-type] + return out + for at, side in enumerate(sides): + mine = [m for m in markets if m.yes_side is not None and m.yes_side.key == side.key] + if mine: + market = nearest_even(mine) + out[at] = pick_of(market, "yes", button_label(kind, side, market.line), kind) + return out + + +def button_label(kind: str, side: Side, line: Decimal | None) -> str: + """`CHI`, `CHI -2.5`, `NE +2.5`; a participant with no venue abbreviation + by its name (the page truncates it).""" + name = side.abbr or side.name + if kind == "spread" and line is not None: + return f"{name} {'+' if line > 0 else ''}{format_line(line)}" + return name + + +def pick_of(market: BoardMarket, outcome: Literal["yes", "no"], label: str, kind: str) -> BoardPick: + price = market.yes_ask if outcome == "yes" else (1 - market.yes_bid if market.yes_bid is not None else None) + return BoardPick(market_id=market.market_id, outcome=outcome, label=label, kind=kind, + line=format_line(market.line) if market.line is not None else None, title=market.title, + yes_bid=market.yes_bid, yes_ask=market.yes_ask, + price=price if price is not None and 0 < price < 1 else None) + + +# --------------------------------------------------------------------------- +# Venue rows -> BoardMarket +# --------------------------------------------------------------------------- + +# Polymarket (Gamma `sportsMarketType`) and Polymarket US (`marketType`, +# `sportsMarketTypeV2`): the full-game types a game card draws. Every other +# type (a half, a map, an exact score, a player prop) is a question. +POLYMARKET_KINDS = {"moneyline": "moneyline", "spreads": "spread", "totals": "total"} +POLYMARKET_US_KINDS = {"moneyline": "moneyline", "drawable_outcome": "moneyline", "spread": "spread", + "spreads": "spread", "total": "total", "totals": "total", + "SPORTS_MARKET_TYPE_MONEYLINE": "moneyline", + "SPORTS_MARKET_TYPE_DRAWABLE_OUTCOME": "moneyline", + "SPORTS_MARKET_TYPE_SPREAD": "spread", "SPORTS_MARKET_TYPE_TOTAL": "total"} +# Kalshi: the event's `product_metadata.competition_scope` (the series' +# `product_metadata.scope` says the same). +KALSHI_SCOPES = {"game": "moneyline", "match": "moneyline", "fight": "moneyline", "spread": "spread", + "point total": "total", "total": "total"} +DRAW_WORDS = ("draw", "tie") + + +def polymarket_markets(rows: Iterable[Any], details: dict[str, dict[str, Any]] | None = None, + events: dict[str, dict[str, Any]] | None = None) -> list[BoardMarket]: + """Polymarket's catalogue rows (`ComboMarket`, `info` the catalogue's own + row), with Gamma's market (`details`, by market id: `sportsMarketType`, + `line`, `groupItemTitle`, `gameStartTime`, `events`) and event (`events`, + by event id: `teams` with `abbreviation`, `alias`, `ordering`) where read. + Without Gamma's market a row is a question: the catalogue names no type.""" + details, events = details or {}, events or {} + out = [] + for rows_done, row in enumerate(rows, 1): + share_the_lock(rows_done) + info = dict(getattr(row, "info", None) or {}) + native = str(row.market_id).partition(":")[2] or str(row.market_id) + gamma = details.get(native) or {} + event = (gamma.get("events") or [{}])[0] if isinstance(gamma.get("events"), list) else {} + event_id = str(event.get("id") or "") + teams = teams_of((events.get(event_id) or {}).get("teams") or event.get("teams") or []) + outcomes = outcome_names(gamma.get("outcomes") or info.get("outcomes")) + prices = [to_decimal(p) for p in outcome_names(gamma.get("outcomePrices") or info.get("outcome_prices"))] + kind = POLYMARKET_KINDS.get(str(gamma.get("sportsMarketType") or "")) + label = gamma.get("groupItemTitle") or None + yes_side, no_side = listed_sides(outcomes, label, teams) + title = str(gamma.get("question") or row.title or info.get("title") or row.market_id) + out.append(BoardMarket( + market_id=row.market_id, title=title, + event_key=f"polymarket-event:{event_id}" if event_id else str(row.market_id), + event_title=event.get("title") or None, kind=kind if yes_side is not None or kind == "total" else None, + outcomes=outcomes, label=label, participants=teams, yes_side=yes_side, no_side=no_side, + line=to_decimal(gamma.get("line")), tags=[str(t) for t in row.tags or []], + series=event.get("seriesSlug") or None, + yes_bid=to_decimal(gamma.get("bestBid")) or (prices[0] if prices and prices[0] else None), + yes_ask=to_decimal(gamma.get("bestAsk")) or (prices[0] if prices and prices[0] else None), + start_time=millis(gamma.get("gameStartTime") or event.get("startTime") or gamma.get("endDate")), + volume=to_decimal(info.get("volume") or gamma.get("volume")), + )) + return out + + +def polymarket_us_markets(rows: Iterable[Any]) -> list[BoardMarket]: + """Polymarket US's eligible rows (`ComboMarket`, `info` the gateway's + market with its `event`): the type from `marketType` / + `sportsMarketTypeV2`, each outcome's participant from `marketSides[].team` + (`name`, `displayAbbreviation`), the event's `teams` in order.""" + out = [] + for rows_done, row in enumerate(rows, 1): + share_the_lock(rows_done) + info = dict(getattr(row, "info", None) or {}) + event = info.get("event") if isinstance(info.get("event"), dict) else {} + teams = teams_of(event.get("teams") or [], abbr_field="displayAbbreviation") + outcomes = outcome_names(info.get("outcomes")) + prices = [to_decimal(p) for p in outcome_names(info.get("outcomePrices"))] + kind = POLYMARKET_US_KINDS.get(str(info.get("marketType") or "")) \ + or POLYMARKET_US_KINDS.get(str(info.get("sportsMarketTypeV2") or "")) + sides = [side_of_team(s.get("team"), teams) for s in info.get("marketSides") or [] if isinstance(s, dict)] + yes_side = no_side = None + if kind == "moneyline" and yes_no(outcomes): + # A drawable result: one market per team, and one for the draw (no team). + yes_side = sides[0] if sides and sides[0] is not None else Side(key="draw", name="Draw", draw=True) + elif kind in ("moneyline", "spread") and len(sides) == 2: + yes_side, no_side = sides + best_bid, best_ask = info.get("bestBidQuote") or {}, info.get("bestAskQuote") or {} + out.append(BoardMarket( + market_id=row.market_id, title=str(info.get("title") or row.title or info.get("question") or row.market_id), + event_key=row.event_id or str(row.market_id), event_title=row.event_title or event.get("title"), + kind=kind if yes_side is not None or kind == "total" else None, outcomes=outcomes, + label=info.get("titleShort") or None, participants=teams, yes_side=yes_side, no_side=no_side, + line=to_decimal(info.get("line")), tags=[str(t) for t in row.tags or []], + series=info.get("seriesSlug") or event.get("seriesSlug"), + yes_bid=to_decimal(best_bid.get("value") if isinstance(best_bid, dict) else None) + or (prices[0] if prices and prices[0] else None), + yes_ask=to_decimal(best_ask.get("value") if isinstance(best_ask, dict) else None) + or (prices[0] if prices and prices[0] else None), + start_time=millis(info.get("gameStartTime") or event.get("startTime") or info.get("endDate")), + volume=to_decimal(info.get("volume")), + )) + return out + + +def listed_markets(rows: Iterable[Any]) -> list[BoardMarket]: + """A listed venue's rows, Polymarket US's or Polymarket's (no Gamma details).""" + rows = list(rows) + us = [r for r in rows if getattr(r, "venue", "") == "polymarket_us"] + return polymarket_us_markets(us) + polymarket_markets([r for r in rows if r not in us]) + + +def kalshi_markets(events: Iterable[Event]) -> list[BoardMarket]: + """Kalshi events (with their markets) a combo collection takes. A game's + events (winner, spread, total) share the event's `sub_title` within one + competition (`NE vs CHI (Oct 22)`); the type is the event's + `product_metadata.competition_scope`; a market's participant is its + `custom_strike` (the team's id) and `yes_sub_title`; codes are the event + `sub_title`'s, side by side with the title's names (`New England vs + Chicago`); a spread's and a total's line is the market's `floor_strike`.""" + out = [] + for events_done, event in enumerate(events, 1): + share_the_lock(events_done) + raw = dict(event.info or {}) + meta = raw.get("product_metadata") or {} + kind = KALSHI_SCOPES.get(str(meta.get("competition_scope") or "").strip().lower()) + sub = str(raw.get("sub_title") or "") + teams = kalshi_sides(event.title, sub) + series = str(raw.get("series_ticker") or event.venue_event_id.partition("-")[0]) + game = f"kalshi-game:{meta.get('competition') or series}:{sub}" if kind and sub else event.id + tags = [*(event.tags or []), *([event.category] if event.category else [])] + rows = [] + for market in event.markets: + if market.status not in ("open", "active") and not market.active: + continue + info = dict(market.info or {}) + label = info.get("yes_sub_title") or getattr(market, "outcome_label", None) or None + side = kalshi_side_of(info, label, teams) if kind in ("moneyline", "spread") else None + floor = to_decimal(info.get("floor_strike")) + line = -floor if kind == "spread" and floor is not None else floor if kind == "total" else None + quote = market.yes.quote + rows.append(BoardMarket( + market_id=market.id, title=market.title, event_key=game, event_title=game_title(event.title), + kind=kind if side is not None or kind == "total" else None, + label=label if label and label.lower() not in ("yes", "no") else None, + participants=teams, yes_side=side, line=line, tags=tags, + series=series.upper().removeprefix("KX"), yes_bid=to_decimal(quote.bid), yes_ask=to_decimal(quote.ask), + start_time=market.close_timestamp, + volume=to_decimal(market.stats.volume_total) if getattr(market, "stats", None) else None, + )) + out.extend(rows) + return relink_kalshi_sides(out) + + +def relink_kalshi_sides(markets: list[BoardMarket]) -> list[BoardMarket]: + """A game's events list one participant list; spread markets name their + team by the same custom strike as the winner market, so every market of a + game takes the moneyline's sides (names, codes) by that key.""" + by_game: dict[str, dict[str, Side]] = {} + for m in markets: + if m.kind == "moneyline" and m.yes_side is not None: + by_game.setdefault(m.event_key, {})[m.yes_side.key] = m.yes_side + for m in markets: + known = by_game.get(m.event_key) or {} + if m.yes_side is not None and m.yes_side.key in known: + m.yes_side = known[m.yes_side.key] + ordered = [next((s for s in known.values() if same_name(s.name, t.name)), t) for t in m.participants] + m.participants = [s for s in ordered] or list(known.values()) + return markets + + +def kalshi_sides(title: str, sub_title: str) -> list[Side]: + """`New England vs Chicago` and `NE vs CHI (Oct 22)` -> both sides with codes.""" + names = vs_sides(title) + codes = vs_sides(re.sub(r"\s*\([^)]*\)\s*$", "", sub_title)) + if len(names) != 2: + return [] + return [Side(key=f"name:{n.lower()}", name=n, abbr=codes[i] if len(codes) == 2 else None) + for i, n in enumerate(names)] + + +def kalshi_side_of(info: dict[str, Any], label: str | None, teams: list[Side]) -> Side | None: + """The participant a Kalshi market's YES backs: its custom strike (the + team's id) and its subtitle (`Chicago`, `Tie`, `Chicago wins by over 2.5 points`).""" + strike = info.get("custom_strike") or {} + key = next((str(v) for v in strike.values()), None) if isinstance(strike, dict) else None + if not label: + return None + if label.strip().lower() in DRAW_WORDS: + return Side(key=key or "draw", name="Draw", draw=True) + team = next((t for t in teams if same_name(t.name, label)), None) \ + or next((t for t in teams if label.lower().startswith(t.name.lower() + " ")), None) + if team is None: + return Side(key=key, name=label) if key else None + # A winner market names its team in full (`Clement Chidekh`); a spread names it as the title does. + return Side(key=key or team.key, name=label if same_name(team.name, label) else team.name, abbr=team.abbr) + + +def teams_of(raw: list[Any], *, abbr_field: str = "abbreviation") -> list[Side]: + """An event's `teams` (Gamma, the Polymarket US gateway) as sides.""" + out = [] + for team in raw: + if not isinstance(team, dict) or not team.get("name"): + continue + abbr = team.get(abbr_field) or team.get("abbreviation") or None + alias = str(team.get("alias") or "").strip() or None + out.append(Side(key=f"team:{team.get('id') or team['name']}", name=str(team["name"]), + abbr=str(abbr).upper() if abbr else None, alias=alias if alias != team["name"] else None)) + return out + + +def side_of_team(team: Any, teams: list[Side]) -> Side | None: + if not isinstance(team, dict) or not team.get("name"): + return None + key = f"team:{team.get('id') or team['name']}" + return next((t for t in teams if t.key == key), None) or teams_of([team], abbr_field="displayAbbreviation")[0] + + +def listed_sides(outcomes: list[str], label: str | None, teams: list[Side]) -> tuple[Side | None, Side | None]: + """Who a Polymarket market's outcomes back: a two-sided market's outcome + names (`Patriots` / `Bears`, matched to the event's teams by name or + alias), else a YES / NO market's own label (`Athletic Club`, `Draw (...)`).""" + def team(name: str) -> Side: + return next((t for t in teams if same_name(t.name, name)), None) \ + or Side(key=f"name:{name.lower()}", name=name) + + if len(outcomes) == 2 and not yes_no(outcomes): + if {o.lower() for o in outcomes} == {"over", "under"}: + return None, None + return team(outcomes[0]), team(outcomes[1]) + if label: + if label.strip().lower().split(" ")[0] in DRAW_WORDS: + return Side(key="draw", name="Draw", draw=True), None + return team(label), None + return None, None + + +def category_of(tags: Iterable[str], series: str | None = None) -> tuple[str, str, Literal["sports", "other"]]: + words = [str(t).lower() for t in tags if t] + if series: + words.insert(0, series.lower()) + for key, label, names in LEAGUES: + for word in words: + if word in names or any(word.startswith(n) for n in names if len(n) >= 3 and word.isalpha()): + return key, label, "sports" if key in SPORTS else "other" + if any(w in ("sports", "games") for w in words): + return "sports", "Sports", "sports" + return OTHER[0], OTHER[1], "other" + + +def league_of(tags: Iterable[str], series: str | None = None) -> str: + return category_of(tags, series)[0] + + +def league_of_series(series: str) -> str: + return category_of([], series.upper().removeprefix("KX"))[0] + + +# --------------------------------------------------------------------------- +# Small helpers +# --------------------------------------------------------------------------- + +def yes_no(outcomes: list[str]) -> bool: + return [o.lower() for o in outcomes] == ["yes", "no"] + + +def outcome_names(raw: Any) -> list[str]: + if isinstance(raw, str): + import json + try: + raw = json.loads(raw) + except ValueError: + return [] + return [str(x) for x in raw] if isinstance(raw, list) else [] + + +def same_name(a: str, b: str) -> bool: + """`Patriots` and `New England Patriots`, `Chidekh` and `Clement Chidekh` + (one name inside the other, by whole words); not `Ohio State` and `Penn State`.""" + a, b = " ".join(a.lower().split()), " ".join(b.lower().split()) + if not a or not b: + return False + return a == b or f" {a} " in f" {b} " or f" {b} " in f" {a} " + + +def vs_sides(text: str) -> list[str]: + """`333: Jasmin Lucindo vs Nicolle Caliari` -> both names; a `: Spread` tail is dropped.""" + parts = re.split(r"\s+(?:vs\.?|v\.?|at|@)\s+", text.strip(), flags=re.I) + if len(parts) != 2: + return [] + first = parts[0].rsplit(": ", 1)[-1].strip() + second = parts[1].split(": ", 1)[0].strip() + return [first, second] if first and second else [] + + +def short(outcome: str) -> str: + return {"over": "O", "under": "U"}.get(outcome.lower(), outcome) + + +def nearest_even(markets: list[BoardMarket]) -> BoardMarket: + def distance(m: BoardMarket) -> Decimal: + price = mid(m) + return abs(price - Decimal("0.5")) if price is not None else Decimal(1) + return min(markets, key=distance) + + +def mid(market: BoardMarket) -> Decimal | None: + if market.yes_bid is not None and market.yes_ask is not None: + return (market.yes_bid + market.yes_ask) / 2 + return market.yes_ask if market.yes_ask is not None else market.yes_bid + + +def format_line(value: Decimal) -> str: + return f"{value.normalize():f}" + + +def game_title(title: str) -> str: + """`New England vs Chicago: Spread` -> `New England vs Chicago`.""" + return re.split(r":\s(?=[^:]*$)|\s[-–]\s|\?$", title, maxsplit=1)[0].strip() or title + + +def to_decimal(value: Any) -> Decimal | None: + if value is None or value == "": + return None + try: + return Decimal(str(value)) + except Exception: + return None + + +def millis(value: Any) -> int | None: + if not value: + return None + if isinstance(value, (int, float)): + return int(value if value > 1e11 else value * 1000) + from datetime import datetime + text = str(value).replace("Z", "+00:00") + if " " in text and "T" not in text: + text = text.replace(" ", "T", 1) + if text.endswith("+00"): + text += ":00" + try: + return int(datetime.fromisoformat(text).timestamp() * 1000) + except ValueError: + return None + + +# --------------------------------------------------------------------------- +# Leg labels: a short name for each market on a board +# --------------------------------------------------------------------------- + +# A label longer than this is cut, with an ellipsis. +LABEL_LENGTH = 60 + + +def market_labels(board: ComboBoard) -> dict[str, str]: + """A short readable name for every market on the board, for a person to + recognise a leg by (`Al Fayha to win`, `Patriots -2.5`, `Patriots vs. + Bears over 41.5`, `U.S. invades Iran`): keyed by market id, and by + `market_id|outcome` where the two outcomes back different participants + (a two-sided moneyline or spread, a total). `label_of` reads it.""" + labels: dict[str, str] = {} + for events_done, event in enumerate(board.events, 1): + share_the_lock(events_done) + if event.kind == "game": + game_labels(event, labels) + else: + for pick in (event.yes, event.no): + if pick is not None: + labels.setdefault(pick.market_id, short_question(pick.title or event.title)) + return labels + + +def game_labels(event: BoardEvent, labels: dict[str, str]) -> None: + """A game's picks: the participant (by its short name) and what it backs.""" + for row in event.rows: + name = row.alias or row.name + for kind, pick in row.picks.items(): + if pick is None: + continue + if kind == "moneyline": + text = f"Draw in {event.title}" if name == "Draw" else f"{name} to win" + elif kind == "spread": + # The button says `CHI -2.5`: the signed line is its last word. + text = f"{name} {pick.label.rsplit(' ', 1)[-1]}" if " " in pick.label else name + else: + text = f"{event.title} {'over' if pick.outcome == 'yes' else 'under'} {pick.line or ''}".strip() + labels[f"{pick.market_id}|{pick.outcome}"] = text + two_sided = kind == "total" or any( + other is not row and (other.picks.get(kind) or None) is not None + and other.picks[kind].market_id == pick.market_id for other in event.rows) # type: ignore[union-attr] + labels.setdefault(pick.market_id, f"{event.title} {kind}" if two_sided else text) + + +def label_of(labels: dict[str, str], market_id: str, outcome: str | None = None) -> str | None: + """A leg's label from `market_labels`: its outcome's where the outcomes + differ, else its market's.""" + return (labels.get(f"{market_id}|{outcome}") if outcome else None) or labels.get(market_id) + + +_WHEN = re.compile( + r"\s+(?:on|by|before|after|in|during|at|through|until|ending)\s+(?:the\s+end\s+of\s+)?" + r"(?:\d{4}-\d{2}-\d{2}|(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\.?\s+\d{1,2}(?:st|nd|rd|th)?" + r"(?:,?\s+\d{4})?|(?:q[1-4]\s+)?\d{4}|today|tonight|tomorrow|this\s+(?:week|month|year))$", re.I) +_IRREGULAR = {"be": "is", "have": "has", "do": "does", "go": "goes"} + + +def short_question(title: str, *, length: int = LABEL_LENGTH) -> str: + """A market's question as a short statement: `Will the U.S. invade Iran + before 2027?` -> `U.S. invades Iran`; `Will Al Fayha Saudi Club win on + 2026-10-22?` -> `Al Fayha Saudi Club wins`. The question's own words, + without its `Will`, its question mark and a trailing date; anything else + is kept as it is, cut to `length`.""" + text = " ".join(str(title or "").split()).rstrip("?").strip() + text = _WHEN.sub("", text).strip() + if text.lower().startswith("will "): + words = text[5:].split(" ") + if len(words) > 1 and words[0].lower() == "the" and words[1][:1].isupper(): + words = words[1:] + for at, word in enumerate(words): + if at and word.isalpha() and word.islower(): + if word != "not": + words[at] = third_person(word) + break + text = " ".join(words) + if len(text) > length: + text = text[:length - 1].rstrip() + "…" + return text or str(title) + + +def third_person(verb: str) -> str: + """`win` -> `wins`, `reach` -> `reaches`, `carry` -> `carries`, `be` -> `is`.""" + if verb in _IRREGULAR: + return _IRREGULAR[verb] + if verb.endswith(("s", "sh", "ch", "x", "z", "o")): + return verb + "es" + if verb.endswith("y") and len(verb) > 1 and verb[-2] not in "aeiou": + return verb[:-1] + "ies" + return verb + "s" + + +# --------------------------------------------------------------------------- +# Serving a board in parts: the rail, and one category a page at a time +# --------------------------------------------------------------------------- + +PAGE_LIMIT = 50 +MAX_PAGE_LIMIT = 500 +BoardSort = Literal["board", "volume", "start"] + + +def categories_view(board: ComboBoard) -> ComboBoardCategories: + return ComboBoardCategories(venue=board.venue, status=board.status, built_at=board.built_at, + complete=board.complete, count=board.count, events=len(board.events), + categories=board.categories, error=board.error) + + +def board_page(board: ComboBoard, *, category: str | None = None, cursor: str | None = None, + limit: int = PAGE_LIMIT, query: str | None = None, sort: BoardSort = "board") -> ComboBoard: + """One page of the board: the events of `category` (`all` or none: every + one) whose title, category or participants hold every word of `query`, + in board order (sports first, the most events first, by start), or by + `volume` (largest first) or `start` within each category; from `cursor` + (an offset from the last page), `limit` events. The categories are the + whole board's, for the rail.""" + events = [e for e in board.events if category in (None, "", "all") or e.category == category] + words = [w for w in (query or "").lower().split() if w] + if words: + events = [e for e in events if all(w in searchable(e) for w in words)] + if sort != "board": + order = {c.key: index for index, c in enumerate(board.categories)} + + def key(e: BoardEvent) -> tuple: + within = -(e.volume or Decimal(0)) if sort == "volume" else Decimal(e.start_time or 2**62) + return (order.get(e.category, len(order)), within, e.title) + events = sorted(events, key=key) + start = page_offset(cursor) + limit = max(1, min(int(limit), MAX_PAGE_LIMIT)) + page = events[start:start + limit] + more = start + limit < len(events) + return board.model_copy(update={"events": page, "category": category or "all", "total": len(events), + "next_cursor": str(start + limit) if more else None}) + + +def searchable(event: BoardEvent) -> str: + return " ".join([event.title, event.category_label, + *(f"{r.name} {r.abbr or ''} {r.alias or ''}" for r in event.rows)]).lower() + + +def page_offset(cursor: str | None) -> int: + if not cursor: + return 0 + try: + return max(0, int(cursor)) + except ValueError: + from ..errors import BadRequest + raise BadRequest(f"not a cursor of this board: {cursor!r}") from None + + +# --------------------------------------------------------------------------- +# Reading a venue +# --------------------------------------------------------------------------- + +# Pages of a venue's eligible list (or of Kalshi's collections) a board read +# on request reads. serve's warmer (`synpath.server.combo_warmer`) reads every +# page (`pages=None`). +BOARD_PAGES = 5 +# Kalshi: the most collection events whose markets a board read on request +# reads (`max_events=None`: every one). +BOARD_EVENTS = 150 +# Gamma's largest batch. +PRICE_BATCH = 100 +KALSHI_EVENT_BATCH = 100 +EVENT_BATCH = 20 + + +# A whole-list build with `progress` publishes its board after this many +# pages (the venue lists by volume, so the busiest markets come first)... +FIRST_PUBLISH_PAGES = 5 +# ...and again every this many pages, until the list ends. +PUBLISH_EVERY_PAGES = 40 + + +class BoardCache: + """What a board keeps between builds of one venue: Polymarket's Gamma + markets (their type, line, label and event: fields that do not change; + prices are left out, so each build takes the catalogue's own) and the + games' events (their teams). A rebuild reads Gamma only for markets it + has not seen; what a build no longer lists is dropped.""" + + def __init__(self) -> None: + self.markets: dict[str, dict[str, Any]] = {} + self.events: dict[str, dict[str, Any]] = {} + + def prune(self, market_natives: set[str], event_ids: set[str]) -> None: + self.markets = {k: v for k, v in self.markets.items() if k in market_natives} + self.events = {k: v for k, v in self.events.items() if k in event_ids} + + +GAMMA_FIELDS = ("question", "sportsMarketType", "line", "groupItemTitle", "gameStartTime", "endDate", "outcomes") +GAMMA_EVENT_FIELDS = ("id", "title", "seriesSlug", "startTime", "teams") + + +def static_gamma(info: dict[str, Any]) -> dict[str, Any]: + """A Gamma market's fields a board reads that do not move with the market.""" + out = {k: info[k] for k in GAMMA_FIELDS if info.get(k) is not None} + events = info.get("events") + if isinstance(events, list) and events and isinstance(events[0], dict): + out["events"] = [{k: events[0][k] for k in GAMMA_EVENT_FIELDS if events[0].get(k) is not None}] + return out + + +async def board_for(venue: str, adapter: Any, reader: Any | None = None, *, pages: int | None = BOARD_PAGES, + max_events: int | None = BOARD_EVENTS, pause_s: float = 0.0, cache: BoardCache | None = None, + progress: Callable[[ComboBoard], None | Awaitable[None]] | None = None) -> ComboBoard: + """The board of one combo venue. `adapter` is the engine's trading + adapter (its eligibility: Kalshi's collections, or the venue's eligible + list); `reader` is the server's read adapter for the venue, where there is + one: Kalshi's event markets come from it (`fetch_events_by_ids`); + Polymarket's rows get its Gamma markets (types, lines, labels, quotes) and + the games' events (teams); Polymarket US's rows carry their own. + + `pages` (None: until the venue's list ends) and `max_events` (None: no + limit) bound the read; `pause_s` is slept between two venue reads, so a + whole-catalogue build stays polite to the venue. `cache` keeps + Polymarket's Gamma reads between builds (`BoardCache`). `progress`, where + given, is handed the board so far (`complete: false`) after the first + `FIRST_PUBLISH_PAGES` pages of a listed venue and every + `PUBLISH_EVERY_PAGES` after: Polymarket lists about 214,000 markets. + + The CPU-heavy steps (rows into board markets, markets into the board) run + in worker threads: on the whole catalogue they take seconds, and serve's + event loop must keep answering the terminal's health checks meanwhile.""" + import time + + from .rfq_group import lists_combo_markets + + now = int(time.time() * 1000) + if callable(getattr(adapter, "fetch_combo_collections", None)): + tickers, complete = await collection_events(adapter, pages, pause_s) + fetch = getattr(reader, "fetch_events_by_ids", None) + if fetch is None or not tickers: + return ComboBoard(venue=venue, count=0, complete=complete and not tickers, as_of=now) + wanted = tickers if max_events is None else tickers[:max_events] + events = await kalshi_events_of(venue, wanted, fetch, pause_s) + markets = await asyncio.to_thread(kalshi_markets, events) + return await asyncio.to_thread(board_of, venue, markets, complete=complete and len(wanted) == len(tickers), + as_of=now) + if not lists_combo_markets(adapter): + return ComboBoard(venue=venue, count=0, complete=True, as_of=now) + return await listed_board(venue, adapter, reader, pages=pages, pause_s=pause_s, cache=cache, progress=progress, + now=now) + + +async def listed_board(venue: str, adapter: Any, reader: Any | None, *, pages: int | None, pause_s: float, + cache: BoardCache | None, progress: Callable[[ComboBoard], None | Awaitable[None]] | None, + now: int) -> ComboBoard: + """A listed venue's board (Polymarket, Polymarket US): its eligible list + page by page, each chunk of rows turned into board markets as it comes, + the board so far handed to `progress` along the way.""" + cache = cache if cache is not None else BoardCache() + markets: list[BoardMarket] = [] + seen: set[str] = set() + pending: list[Any] = [] + cursor: str | None = None + read = 0 + complete = False + while pages is None or read < pages: + if read: + await pause(pause_s) + page = await adapter.fetch_combo_eligible_markets(cursor=cursor) + read += 1 + for row in page: + if row.market_id not in seen: + seen.add(row.market_id) + pending.append(row) + cursor = page.next_cursor + if not cursor: + complete = True + break + if progress is not None and (read == FIRST_PUBLISH_PAGES or read % PUBLISH_EVERY_PAGES == 0): + markets += await listed_markets_of(venue, pending, reader, pause_s, cache) + pending = [] + # The board so far is long-lived: keep the cyclic collector from + # rescanning it on every full collection (`share_the_lock`). + gc.freeze() + published = progress(await asyncio.to_thread(board_of, venue, markets, complete=False, as_of=now)) + if inspect.isawaitable(published): + await published + markets += await listed_markets_of(venue, pending, reader, pause_s, cache) + if complete: + cache.prune({m.market_id.partition(":")[2] for m in markets}, + {m.event_key.partition(":")[2] for m in markets if m.event_key.startswith("polymarket-event:")}) + return await asyncio.to_thread(board_of, venue, markets, complete=complete, as_of=now) + + +async def listed_markets_of(venue: str, rows: list[Any], reader: Any | None, pause_s: float, + cache: BoardCache) -> list[BoardMarket]: + """Board markets for a chunk of a listed venue's rows: Polymarket US's + carry their own fields; Polymarket's take Gamma's market (from `cache`, + else read now, with its prices) and their game's event (teams).""" + if not rows: + return [] + can_read = reader is not None and callable(getattr(reader, "fetch_markets_by_ids", None)) + if venue == "polymarket_us" or all(getattr(r, "venue", "") == "polymarket_us" for r in rows): + markets = await asyncio.to_thread(polymarket_us_markets, rows) + if can_read: + await with_catalogue_quotes(markets, reader, pause_s) + return markets + natives, unseen = await asyncio.to_thread(natives_not_cached, rows, cache) + quotes: dict[str, Market] = {} + if can_read and unseen: + quotes = await catalogue_markets(unseen, reader, pause_s) + details = await asyncio.to_thread(remember_gamma_markets, natives, unseen if can_read else [], quotes, cache) + fetch_events = getattr(reader, "fetch_events_by_ids", None) + if callable(fetch_events): + missing = await asyncio.to_thread(game_events_not_cached, details, cache) + for event_id, info in (await game_events(missing, fetch_events, pause_s)).items(): + cache.events[event_id] = {"teams": info.get("teams") or []} + markets = await asyncio.to_thread(polymarket_markets, rows, details, cache.events) + await asyncio.to_thread(apply_quotes, markets, quotes) + return markets + + +# The bookkeeping of one chunk of Polymarket rows (about 40,000 markets), kept +# off serve's event loop like the builders above (`share_the_lock`). + +def natives_not_cached(rows: list[Any], cache: BoardCache) -> tuple[dict[str, str], list[str]]: + """Each row's native Gamma id, by market id, and the market ids whose + Gamma market the cache does not hold yet.""" + natives: dict[str, str] = {} + for rows_done, row in enumerate(rows, 1): + share_the_lock(rows_done) + natives[row.market_id] = str(row.market_id).partition(":")[2] or str(row.market_id) + return natives, [m for m, n in natives.items() if n not in cache.markets] + + +def remember_gamma_markets(natives: dict[str, str], unseen: list[str], quotes: dict[str, Market], + cache: BoardCache) -> dict[str, dict[str, Any]]: + """Gamma's market for each row, by native id: the one just read (with + its prices), else the cache's. What was just read goes into the cache; + a market Gamma did not serve is cached empty, so it is not asked for again.""" + for market_id in unseen: + cache.markets.setdefault(natives[market_id], {}) + fresh: dict[str, dict[str, Any]] = {} + for quotes_done, market in enumerate(quotes.values(), 1): + share_the_lock(quotes_done) + info = dict(market.info or {}) + fresh[market.venue_market_id] = info + cache.markets[market.venue_market_id] = static_gamma(info) + return {n: fresh.get(n) or cache.markets[n] for n in natives.values() if n in fresh or n in cache.markets} + + +def game_events_not_cached(details: dict[str, dict[str, Any]], cache: BoardCache) -> list[str]: + """The Gamma event ids of the chunk's games whose teams the cache lacks.""" + wanted = list(dict.fromkeys( + str((d.get("events") or [{}])[0].get("id")) for d in details.values() + if POLYMARKET_KINDS.get(str(d.get("sportsMarketType") or "")) and d.get("events"))) + return [e for e in wanted if e not in cache.events] + + +async def pause(seconds: float) -> None: + if seconds > 0: + import asyncio + await asyncio.sleep(seconds) + + +async def collection_events(adapter: Any, pages: int | None, pause_s: float = 0.0) -> tuple[list[str], bool]: + """Every event ticker an open collection takes, in the venue's order.""" + tickers: list[str] = [] + cursor: str | None = None + read = 0 + while pages is None or read < pages: + if read: + await pause(pause_s) + page = await adapter.fetch_combo_collections(status="open", limit=200, cursor=cursor) + read += 1 + for row in page: + tickers.extend(str(t) for t in row.get("associated_event_tickers") or []) + cursor = page.next_cursor + if not cursor or not page: + return list(dict.fromkeys(tickers)), True + return list(dict.fromkeys(tickers)), False + + +async def kalshi_events_of(venue: str, tickers: list[str], fetch: Any, pause_s: float) -> list[Event]: + """Kalshi's collection events with their markets, in batches.""" + import asyncio + + out: list[Event] = [] + for start in range(0, len(tickers), KALSHI_EVENT_BATCH): + if start: + await pause(pause_s) + out.extend(await asyncio.to_thread(fetch, [f"{venue}:{t}" for t in tickers[start:start + KALSHI_EVENT_BATCH]])) + return out + + +async def catalogue_markets(wanted: list[str], reader: Any, pause_s: float = 0.0) -> dict[str, Market]: + """The catalogue's markets for these ids, best effort, by id.""" + import asyncio + + found: dict[str, Market] = {} + for start in range(0, len(wanted), PRICE_BATCH): + if start: + await pause(pause_s) + try: + batch = await asyncio.to_thread(reader.fetch_markets_by_ids, wanted[start:start + PRICE_BATCH]) + except Exception: + continue + found.update((m.id, m) for m in batch) + return found + + +async def game_events(event_ids: list[str], fetch: Any, pause_s: float = 0.0) -> dict[str, dict[str, Any]]: + """Polymarket's game events (their `teams`), by event id, best effort.""" + import asyncio + + out: dict[str, dict[str, Any]] = {} + for start in range(0, len(event_ids), EVENT_BATCH): + if start: + await pause(pause_s) + try: + batch = await asyncio.to_thread(fetch, [f"polymarket:{e}" for e in event_ids[start:start + EVENT_BATCH]]) + except Exception: + continue + for event in batch: + out[event.venue_event_id] = dict(event.info or {}) + return out + + +def apply_quotes(markets: list[BoardMarket], quotes: dict[str, Market]) -> None: + for market in markets: + held = quotes.get(market.market_id) + if held is None: + continue + quote = held.yes.quote + if quote.bid is not None or quote.ask is not None: + market.yes_bid, market.yes_ask = to_decimal(quote.bid), to_decimal(quote.ask) + + +async def with_catalogue_quotes(markets: list[BoardMarket], reader: Any, pause_s: float = 0.0) -> None: + """Replace listed prices with the catalogue's quotes, best effort.""" + apply_quotes(markets, await catalogue_markets([m.market_id for m in markets], reader, pause_s)) diff --git a/synpath/engine/database.py b/synpath/engine/database.py new file mode 100644 index 0000000..962a2dd --- /dev/null +++ b/synpath/engine/database.py @@ -0,0 +1,192 @@ +"""SQLAlchemy transport preserving the journal's transaction boundary.""" +from __future__ import annotations + +import asyncio +from weakref import WeakSet +from sqlalchemy.exc import DBAPIError +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.engine import URL, make_url +from sqlalchemy.pool import NullPool + + +async def finish_database_operation(awaitable): + """Finish I/O before propagating cancellation to the rollback owner.""" + operation = asyncio.create_task(awaitable) + try: + return await asyncio.shield(operation) + except asyncio.CancelledError: + while not operation.done(): + try: + await asyncio.shield(operation) + except asyncio.CancelledError: + continue + except Exception: + break + try: + operation.result() + except Exception: + pass + raise + + +class Cursor: + def __init__(self, result): + rows = list(result.mappings()) if result.returns_rows else [] + self.rows = iter(rows) + self.rowcount = result.rowcount + self.lastrowid = getattr(result, "lastrowid", None) + if rows: + self.lastrowid = rows[0].get("seq", rows[0].get("version", self.lastrowid)) + + async def fetchone(self): + return next(self.rows, None) + + async def fetchall(self): + return list(self.rows) + + def __aiter__(self): + return self + + async def __anext__(self): + row = next(self.rows, None) + if row is None: + raise StopAsyncIteration + return row + + +class Execution: + def __init__(self, connection, query, args): + self.connection, self.query, self.args = connection, query, args + self.cursor = None + + async def run(self): + if self.cursor is None: + self.cursor = await self.connection.run(self.query, self.args) + return self.cursor + + def __await__(self): + return self.run().__await__() + + async def __aenter__(self): + return await self.run() + + async def __aexit__(self, *exc): + pass + + +class Connection: + def __init__(self, engine, connection, *, postgres=False): + self.engine, self.connection, self.postgres = engine, connection, postgres + self.lock = asyncio.Lock() + self.transaction_owner = None + self.completed_transaction_owners = WeakSet() + + def execute(self, query, args=None): + return Execution(self, query, args) + + async def executescript(self, source): + for statement in source.split(";"): + if statement.strip(): + await self.execute(statement) + + async def _execute(self, query, args=None): + try: + # Cancellation must not invalidate SQLAlchemy's connection while + # another journal operation is about to roll back on that session. + result = await finish_database_operation( + self.connection.exec_driver_sql(query, tuple(args) if args else ()), + ) + except DBAPIError as exc: + # Keep existing public native exception contracts, including + # definitive constraint failures, while SQLAlchemy owns transport. + raise exc.orig from None + return Cursor(result) + + async def run(self, query, args): + if self.postgres: + from .postgres import postgres_query + query = postgres_query(query) + task = asyncio.current_task() + if query in ("BEGIN", "BEGIN IMMEDIATE"): + self.completed_transaction_owners.discard(task) + if self.transaction_owner is task: + raise RuntimeError("nested journal transaction") + await self.lock.acquire() + try: + await self._execute(query) + self.transaction_owner = task + except BaseException: + try: + await finish_database_operation(self._execute("ROLLBACK")) + finally: + self.lock.release() + raise + return CursorResult() + if query in ("COMMIT", "ROLLBACK"): + # A canceled caller may clean up after its shielded COMMIT already + # succeeded. Do not roll back a completed or another task's transaction. + if query == "ROLLBACK" and task in self.completed_transaction_owners: + self.completed_transaction_owners.discard(task) + return CursorResult() + if self.transaction_owner is not task: + raise RuntimeError("transaction belongs to another task") + completed = False + async def finish_transaction(): + nonlocal completed + try: + result = await self._execute(query) + except BaseException: + if query == "ROLLBACK": + self.transaction_owner = None + self.lock.release() + raise + self.transaction_owner = None + self.lock.release() + completed = True + return result + try: + return await finish_database_operation(finish_transaction()) + except asyncio.CancelledError: + if completed and query == "COMMIT": + self.completed_transaction_owners.add(task) + raise + if self.transaction_owner is task: + return await self._execute(query, args) + async with self.lock: + return await self._execute(query, args) + + async def close(self): + try: + await self.connection.close() + finally: + await self.engine.dispose() + + +class CursorResult: + rowcount = 0 + lastrowid = None + + +async def connect_sqlite(path): + return await _connect(URL.create("sqlite+aiosqlite", database=path)) + + +async def connect_postgres(dsn): + if dsn.startswith(("postgresql://", "postgres://")): + url = make_url(dsn.replace("postgres://", "postgresql://", 1)).set(drivername="postgresql+psycopg") + return await _connect(url, postgres=True, connect_args={"connect_timeout": 10}) + # Preserve libpq keyword DSNs without parsing or exposing secrets. + from psycopg import AsyncConnection + async def creator(): + return await AsyncConnection.connect(dsn, connect_timeout=10) + return await _connect("postgresql+psycopg://", postgres=True, async_creator=creator) + + +async def _connect(url, *, postgres=False, **options): + engine = create_async_engine(url, isolation_level="AUTOCOMMIT", poolclass=NullPool, **options) + try: + connection = await engine.connect() + except BaseException: + await engine.dispose() + raise + return Connection(engine, connection, postgres=postgres) diff --git a/synpath/engine/engine.py b/synpath/engine/engine.py index 33d9bd3..857f0c2 100644 --- a/synpath/engine/engine.py +++ b/synpath/engine/engine.py @@ -37,11 +37,12 @@ import asyncio import logging +import os import time import uuid from dataclasses import dataclass, field from decimal import Decimal -from typing import Any, Iterable, Mapping +from typing import Any, Callable, Iterable, Mapping from .. import ids from ..bucket import Bucket, bucket_id_of, is_bucket_id @@ -50,15 +51,20 @@ from ..trading.errors import OrderNotFound, OrderRejected, RiskRejected from ..trading.types import ( VENUE_ORDER_TYPES, Account, EditRequest, Fill, HeldBy, Order, OrderRequest, OrderStatus, OrderType, Position, - Settlement, Side, + Settlement, Side, Precision, ) +from ..trading.instruments import MarketMaster +from .execution_scope import MANAGED_NAMESPACE, BucketNotFound, validate_execution_accounts from .events import EventBus -from .fair_values import FairValues -from .journal import IntentState, Journal, LeaseLost, now_ms +from .journal import IntentState, LeaseLost, now_ms +from .storage import JournalStore, create_journal +from .submissions import CancelUnconfirmed, SubmissionInDoubt, SubmissionPending, request_identity from .ledger import Ledger -from .orders import ManagedOrders, as_gtd +from .orders import ManagedOrderController, as_gtd from .orders.manager import REGISTRY from .risk import Decision, HaltPolicy, RiskConfig, RiskEngine +from .rfq import FOLLOWED, RFQ_OPERATIONS, RfqDesk +from .rfq_group import Matcher, RfqGroupDesk log = logging.getLogger("synpath.engine") @@ -70,29 +76,42 @@ class EngineConfig: """How this engine runs. The risk rules live in `RiskConfig`.""" journal_path: str = "synpath.db" + storage_backend: str = "sqlite" + storage_schema: str = "public" + user_id: str = field(default_factory=lambda: os.environ.get("SYNPATH_USER_ID", "local-user")) lease_name: str = "engine" lease_ttl_ms: int = 30_000 lease_renew_s: float = 10.0 + # How long an unanswered send stays in doubt before the sweep resolves it. in_doubt_timeout_s: float = 20.0 - """How long an unanswered send stays in doubt before the sweep resolves it.""" + # How long the venue must keep saying it has no such order before the + # engine believes it. Venues do not list an order the instant they accept + # it -- Kalshi's demo takes a few hundred milliseconds -- so declaring one + # lost too early would abandon a live order. lost_after_s: float = 60.0 - """How long the venue must keep saying it has no such order before the - engine believes it. Venues do not list an order the instant they accept - it -- Kalshi's demo takes a few hundred milliseconds -- so declaring one - lost too early would abandon a live order.""" sweep_interval_s: float = 10.0 reconcile_interval_s: float = 60.0 + # How often to poll orders and fills when no stream is attached. poll_interval_s: float = 5.0 - """How often to poll orders and fills when no stream is attached.""" halt_policy: HaltPolicy = "cancel" require_lease: bool = True - mark_stale_after_s: float = 300.0 + # How often engine-held orders get their timer. A TWAP slice, an + # iceberg reload and a peg's minimum stay are all measured against it. managed_tick_s: float = 1.0 - """How often engine-held orders get their timer. A TWAP slice, an - iceberg reload and a peg's minimum stay are all measured against it.""" - session_timezone: str | None = None - """Where "today" ends, for Day orders. The machine's zone by default.""" + # Where "today" ends, for Day orders. Europe/London by default, including seasonal clock changes. + session_timezone: str | None = "Europe/London" session_end: str = "23:59:59" + # How often the RFQ follower reads accepted and confirmed quotes, the + # quotes on our open RFQs and our own maker quotes (and expires them). + rfq_poll_interval_s: float = 1.0 + # How often to poll incoming RFQs on quoting venues without a stream + # (`rfq.incoming`); venues that push (`subscribe_incoming_rfqs`) are not + # polled. `None` turns the feed off, streams included. + rfq_incoming_interval_s: float | None = 2.0 + # Start the venues' incoming-RFQ streams (Polymarket global's quoter + # session, SX Bet's parlay channel) when the RFQ loop starts. `False` + # leaves starting them to the caller; RFQs they push still reach the engine. + rfq_stream_incoming: bool = True @dataclass(slots=True) @@ -100,17 +119,17 @@ class Recovery: """What `recover()` found. Every number here is a thing that was in doubt.""" in_doubt: int = 0 + # Intents whose order was found at the venue after all. adopted: int = 0 - """Intents whose order was found at the venue after all.""" + # Intents with no order at the venue: nothing was sent. swept: int = 0 - """Intents with no order at the venue: nothing was sent.""" + # Still in doubt: the venue could not be reached. Trading stays blocked + # for these instruments until it can. unresolved: int = 0 - """Still in doubt: the venue could not be reached. Trading stays blocked - for these instruments until it can.""" fills_replayed: int = 0 orders_open: int = 0 + # Engine-held orders brought back and still running. managed: int = 0 - """Engine-held orders brought back and still running.""" details: list[dict[str, Any]] = field(default_factory=list) def summary(self, *, detail_limit: int = 20) -> dict[str, Any]: @@ -121,9 +140,9 @@ def summary(self, *, detail_limit: int = 20) -> dict[str, Any]: } -POST_ONLY_KINDS = ("iceberg", "peg", "twap") -"""Engine types whose children rest on the book, where `post_only` means -something. A TWAP only in its `limit` style.""" +# Engine types whose children rest on the book, where `post_only` means +# something. A TWAP only in its `limit` style. +POST_ONLY_KINDS = ("iceberg", "peg", "twap", "stop_limit") STOP_KINDS = frozenset({"stop_market", "stop_limit", "trailing_stop"}) @@ -131,7 +150,7 @@ def summary(self, *, detail_limit: int = 20) -> dict[str, Any]: def check_managed_fields(request: OrderRequest, *, now_ms: int) -> None: """Refuse the order fields an engine-held type cannot honour, rather than - ignore them. `reduce_only` always passes to the children; `expires_at` is + ignore them. The outcome and action always pass to the children; `expires_at` is the whole order's expiry.""" kind = str(request.params.get("managed_kind") or request.type.value) if request.post_only: @@ -139,7 +158,7 @@ def check_managed_fields(request: OrderRequest, *, now_ms: int) -> None: raise BadRequest(f"post_only goes on the {kind}'s legs, not on the order itself") style = str(request.params.get("style") or "limit") if kind not in POST_ONLY_KINDS or (kind == "twap" and style != "limit"): - raise BadRequest(f"post_only is for types that rest on the book (iceberg, peg, a limit-style twap); " + raise BadRequest(f"post_only is for types that rest on the book (iceberg, peg, limit-style twap, stop_limit); " f"a {kind} order takes liquidity") if request.expires_at is not None and request.expires_at <= now_ms: raise BadRequest("expires_at is in the past") @@ -168,36 +187,58 @@ def __init__( config: EngineConfig | None = None, *, risk: RiskConfig | None = None, - journal: Journal | None = None, + journal: JournalStore | None = None, accounts: Mapping[str, Account] | None = None, clock: Any = time.time, + matcher: Matcher | None = None, ): self.config = config or EngineConfig() + if MANAGED_NAMESPACE in adapters or MANAGED_NAMESPACE in (accounts or {}): + raise ValueError("the managed storage namespace cannot be configured as a venue") self.adapters: dict[str, TradingExchange] = dict(adapters) - self.journal = journal or Journal(self.config.journal_path) + if not self.config.user_id.strip(): + raise ValueError("user_id must not be empty") + self.journal = journal or create_journal(backend=self.config.storage_backend, + path=self.config.journal_path, user_id=self.config.user_id, schema=self.config.storage_schema) + if self.journal.user_id != self.config.user_id: + raise ValueError("execution engine and journal must use the same user_id") self.bus = EventBus(self.journal) self.ledger = Ledger() - self.fair_values = FairValues(self.journal, stale_after_s=self.config.mark_stale_after_s) - self.risk = RiskEngine(risk or RiskConfig(), ledger=self.ledger, clock=clock) + # Holds the order risk rules, trading halt state and recent submission counters. + self.order_risk_checks = RiskEngine(risk or RiskConfig(), ledger=self.ledger, clock=clock) self.accounts: dict[str, Account] = dict(accounts or {}) for venue in self.adapters: # Every configured venue trades under some account; name the # default one, so listing accounts shows what orders will use. - self.accounts.setdefault(venue, Account(venue=venue)) + adapter_account = getattr(self.adapters[venue], "account", None) + self.accounts.setdefault(venue, adapter_account if isinstance(adapter_account, Account) + else Account(venue=venue)) self.clock = clock self.running = False self.started_ts: int | None = None self._tasks: list[asyncio.Task] = [] + self._dispatch_queue: asyncio.Queue[str] = asyncio.Queue() + self._queued_parents: set[str] = set() + self.dispatch_running = False + self.dispatch_parent: str | None = None self._locks: dict[str, asyncio.Lock] = {} + # Resolution timestamps, for the closing-soon guard; filled by the caller. self.market_close: dict[str, int] = {} - """Resolution timestamps, for the closing-soon guard; filled by the caller.""" + # Market to event id, for the per-event cap. self.event_of: dict[str, str] = {} - """Market to event id, for the per-event cap.""" self._open_cache: dict[str, Order] = {} - self.orders = ManagedOrders(self) + # Creates, restores and advances engine-held orders, and links their venue children. + self.managed_order_controller = ManagedOrderController(self) + # The local books engine-held orders watch, fed by `on_book`. self.books: dict[str, Any] = {} - """The local books engine-held orders watch, fed by `on_book`.""" self.last_trade: dict[str, Decimal] = {} + self.last_trade_timestamps: dict[str, int] = {} + self.market_master = MarketMaster() + # RFQs and combos, both sides (docs/rfq-combo.md); the methods below delegate to it. + self.rfq = RfqDesk(self) + # Cross-venue RFQ groups over the desk (docs/rfq-combo.md, "Cross-venue RFQ"). `matcher` + # maps a market onto another venue; by default the hosted matching service. + self.rfq_groups = RfqGroupDesk(self, matcher=matcher) # -- lifecycle ------------------------------------------------------------ @@ -205,12 +246,11 @@ async def start(self) -> Recovery: await self.journal.open() if self.config.require_lease: await self.journal.acquire_lease(self.config.lease_name, ttl_ms=self.config.lease_ttl_ms) - version = await self.journal.save_config("risk", self.risk.config.model_dump(mode="json")) - self.risk.config_version = version - await self.fair_values.load() + version = await self.journal.save_config("risk", self.order_risk_checks.config.model_dump(mode="json")) + self.order_risk_checks.config_version = version recovery = await self.recover() self.running = True - self.started_ts = now_ms() + self.started_ts = int(self.clock() * 1000) await self.bus.publish("engine.started", { "owner": self.journal.owner, "risk_version": version, "recovery": recovery.summary(), }) @@ -226,6 +266,8 @@ def background(self) -> dict[str, Any]: "sweep": self._sweep_loop(), "poll": self._poll_loop(), "managed": self._managed_loop(), + "dispatch": self._dispatch_loop(), + "rfq": self.rfq.run(), } async def run(self) -> None: @@ -246,7 +288,7 @@ async def stop(self) -> None: except (asyncio.CancelledError, Exception): pass self._tasks = [] - if self.journal._db is not None: + if self.journal.is_open: await self.bus.publish("engine.stopped", {"owner": self.journal.owner}) await self.journal.close() @@ -259,11 +301,196 @@ async def __aexit__(self, *exc: Any) -> None: # -- submitting ----------------------------------------------------------- - async def submit(self, request: OrderRequest, *, venue: str | None = None, mark: Decimal | None = None) -> Order: + async def submit(self, request: OrderRequest, *, venue: str | None = None, risk_reference_price: Decimal | None = None, + precision: Precision | None = None, + authorize_execution_accounts: Callable[[dict[str, Account]], None] | None = None) -> Order: + """Retry the same instruction, never repeat an uncertain venue send. + + `risk_reference_price` overrides the book midpoint for the limit-price risk check. + """ + request = await self._prepare_submission_request(request) + # Bucket parents capture and authorize their cross-venue execution scope separately. + if is_bucket_id(request.market_id): + if venue is not None: + raise BadRequest("a bucket parent has no singular execution venue") + return await self._submit_bucket(request, risk_reference_price=risk_reference_price, + authorize_execution_accounts=authorize_execution_accounts) + return await self._submit_with_retry_protection( + request, venue=venue, risk_reference_price=risk_reference_price, precision=precision, + ) + + async def _prepare_submission_request(self, request: OrderRequest) -> OrderRequest: + """Check execution ownership and entry-point rules, then assign the worker's user.""" + # A caller cannot submit as another user. + if request.user_id not in (None, self.config.user_id): + raise BadRequest("user_id is assigned by the execution worker") + # Only the active lease holder may submit. + if self.config.require_lease and not await self.journal.holds_lease(): + raise LeaseLost("this execution worker no longer holds the journal lease") + # Persist the configured owner on the instruction. + request = request.model_copy(update={"user_id": self.config.user_id}) + # RFQ orders require the quote acceptance/confirmation workflow. + if request.type in (OrderType.RFQ_TAKER, OrderType.RFQ_MAKER): + raise BadRequest(f"a {request.type.value} order comes from a quote: use accept_quote or confirm_quote") + return request + + async def _submit_with_retry_protection( + self, request: OrderRequest, *, venue: str | None, + risk_reference_price: Decimal | None, precision: Precision | None, + ) -> Order: + """Reuse a durable submission or reserve its identity before executing it. + + Venue orders and stops use this retry reservation. Other managed types + retain their own lifecycle path through _submit_once(). Bucket parents + are dispatched separately by submit(). + """ + # Other managed types keep their existing lifecycle path. + supported = VENUE_ORDER_TYPES | {OrderType.STOP_MARKET, OrderType.STOP_LIMIT} + if request.type not in supported or request.params.get("walk"): + return await self._submit_once(request, venue=venue, risk_reference_price=risk_reference_price, precision=precision) + + # Establish the account and stable order ID before looking for earlier attempts. + venue, account, request, caller_supplied_order_id = self._prepare_submission_identity(request, venue=venue) + + # Serialize lookup, reservation and execution for this submission ID. + async with self._lock(f"submission:{request.client_order_id}"): + request, previous_result = await self._resume_or_reserve_submission(request, venue=venue, account=account) + if previous_result is not None: + return previous_result + + # Execute only after confirming this instruction can safely proceed. + try: + return await self._submit_once( + request, venue=venue, risk_reference_price=risk_reference_price, + caller_supplied_order_id=caller_supplied_order_id, precision=precision, + ) + except (RiskRejected, BadRequest, OrderRejected) as error: + # Preserve definite failures so retries observe the same rejection. + await self._record_submission_rejection(request, error) + raise + + def _prepare_submission_identity( + self, request: OrderRequest, *, venue: str | None, + ) -> tuple[str, Account, OrderRequest, bool]: + """Resolve the destination and assign an order ID while retaining its origin.""" + venue = venue or self._venue_of(request) + account = request.account or self.accounts.get(venue) or Account(venue=venue) + # Caller-assigned IDs bypass the accidental-duplicate check, not same-ID retry protection. + caller_supplied_order_id = request.client_order_id is not None + request = request.model_copy(update={ + "account": account, + "client_order_id": request.client_order_id or f"sp-{uuid.uuid4().hex[:20]}", + }) + return venue, account, request, caller_supplied_order_id + + async def _resume_or_reserve_submission( + self, request: OrderRequest, *, venue: str, account: Account, + ) -> tuple[OrderRequest, Order | None]: + """Return an earlier outcome or the effective instruction safe to execute. + + The caller must hold the submission lock through any subsequent execution. + """ + previous_submission = await self.journal.submission(request.client_order_id) + if previous_submission is None: + return await self._reserve_new_submission(request, venue=venue, account=account) + + # Reusing an ID for another instruction or account must never return an unrelated order. + if previous_submission["request"] != request_identity(request): + raise BadRequest("client_order_id already identifies a different instruction or account") + previous_result = await self._existing_submission_result(previous_submission) + if previous_result is not None: + return request, previous_result + + # Keep the original effective expiry; retrying a DAY order must not extend its life. + return OrderRequest.stored(previous_submission["effective_request"]), None + + async def _reserve_new_submission( + self, request: OrderRequest, *, venue: str, account: Account, + ) -> tuple[OrderRequest, Order | None]: + """Reserve a new submission, reconciling any older intent before allowing execution.""" + # Freeze session-relative DAY expiry before saving the instruction used for execution. + effective_request = as_gtd(request, now_s=self.clock(), timezone_name=self.config.session_timezone, + session_end=self.config.session_end) + + # Older durable actions may already own this ID without a submission reservation. + prior_intent = await self.journal.intent(request.client_order_id) + if prior_intent is not None: + prior_request = OrderRequest.stored(prior_intent.request) + if prior_intent.operation != "create" or request_identity(prior_request) != request_identity(effective_request): + raise BadRequest("client_order_id already identifies a different durable action") + elif await self.journal.order_by_client_id(request.client_order_id) is not None: + raise BadRequest("client_order_id belongs to an older order without a verifiable instruction") + + await self.journal.record_submission(request, effective_request, venue=venue, account=account) + if prior_intent is not None: + # Resolve the older send before execution; an unanswered send may already be accepted. + submission = await self.journal.submission(request.client_order_id) + previous_result = await self._existing_submission_result(submission) + if previous_result is not None: + return effective_request, previous_result + return effective_request, None + + async def _record_submission_rejection( + self, request: OrderRequest, error: RiskRejected | BadRequest | OrderRejected, + ) -> None: + """Persist a definite failure without rewriting a send that may have reached the venue.""" + # Only an unsent, planned intent can be marked rejected here. + intent = await self.journal.intent(request.client_order_id) + if intent is not None and intent.state == IntentState.PLANNED: + await self.journal.mark_intent(request.client_order_id, IntentState.REJECTED, detail=str(error)) + await self.journal.reject_submission(request.client_order_id, + {"kind": type(error).__name__, "message": str(error), "rule": getattr(error, "rule", None)}) + + async def _existing_submission_result(self, submission: dict[str, Any]) -> Order | None: + if submission["result_id"]: + parent = self.managed_order_controller.get(submission["result_id"]) + if parent is None: + stored = await self.journal.managed_one(submission["result_id"]) + parent = self._rebuilt(*stored) if stored else None + if parent is not None: + if parent.live and self.managed_order_controller.get(parent.id) is None: + self.managed_order_controller.remember(parent) + return parent.as_order() + result = await self.journal.order(submission["venue"], submission["result_id"]) + if result is None: + raise SubmissionPending("the accepted submission has no readable result; refusing to repeat it", + client_order_id=submission["client_order_id"]) + return result + if submission["state"] == "rejected": + error = submission["error"] + if error["kind"] == "RiskRejected": + raise RiskRejected(error["message"], rule=error["rule"]) + if error["kind"] == "OrderRejected": + raise OrderRejected(error["message"]) + raise BadRequest(error["message"]) + intent = await self.journal.intent(submission["client_order_id"]) + if intent is None or intent.state == IntentState.PLANNED: + return None + if intent.state == IntentState.SENDING: + await self._resolve(intent) + result = await self.journal.order_by_client_id(submission["client_order_id"]) + if result is not None: + return result + if intent.state in (IntentState.REJECTED, IntentState.FAILED, IntentState.SETTLED): + raise OrderRejected(intent.detail or "the earlier submission ended without an accepted order") + raise SubmissionPending("the earlier venue send is unresolved; refusing to send it again", + client_order_id=submission["client_order_id"]) + + async def _submit_once(self, request: OrderRequest, *, venue: str | None = None, + risk_reference_price: Decimal | None = None, caller_supplied_order_id: bool | None = None, + precision: Precision | None = None) -> Order: """Risk, journal, send, record. Raises `RiskRejected` before anything - is sent, or the venue's own error after.""" + is sent, or the venue's own error after. + + `risk_reference_price` is the reference for the limit-price risk check. + `caller_supplied_order_id` skips the accidental-duplicate check when true; + when omitted, infer it before generating an order ID. + """ + if request.user_id not in (None, self.config.user_id): + raise BadRequest("user_id is assigned by the execution worker") + request = request.model_copy(update={"user_id": self.config.user_id}) if is_bucket_id(request.market_id): - return await self._submit_bucket(request, mark=mark) + return await self._submit_bucket(request, risk_reference_price=risk_reference_price) venue = venue or self._venue_of(request) adapter = self._adapter(venue) account = request.account or self.accounts.get(venue) or Account(venue=venue) @@ -271,17 +498,36 @@ async def submit(self, request: OrderRequest, *, venue: str | None = None, mark: request = as_gtd(request, now_s=self.clock(), timezone_name=self.config.session_timezone, session_end=self.config.session_end) if request.type not in VENUE_ORDER_TYPES: - return await self.submit_managed(request, venue=venue, account=account, mark=mark) + return await self.submit_managed(request, venue=venue, account=account, risk_reference_price=risk_reference_price) if request.type == OrderType.MARKET and request.params.get("walk"): # An explicit request for the engine's book walk rather than the # adapter's single immediate limit. walked = request.model_copy(update={"params": {**request.params, "managed_kind": "market_engine"}}) - return await self.submit_managed(walked, venue=venue, account=account, mark=mark) - deliberate = request.client_order_id is not None + return await self.submit_managed(walked, venue=venue, account=account, risk_reference_price=risk_reference_price) + if caller_supplied_order_id is None: + caller_supplied_order_id = request.client_order_id is not None client_order_id = request.client_order_id or f"sp-{uuid.uuid4().hex[:20]}" request = request.model_copy(update={"client_order_id": client_order_id, "account": account}) - decision = self.check(request, venue=venue, account=account, mark=mark, deliberate=deliberate) + await self._check_venue_order_risk( + request, venue=venue, account=account, risk_reference_price=risk_reference_price, + caller_supplied_order_id=caller_supplied_order_id, + ) + order = await self._send_venue_order_with_journaled_intent( + request, venue=venue, account=account, adapter=adapter, precision=precision, + ) + return await self._record_accepted_venue_order(order, request, venue=venue) + + async def _check_venue_order_risk( + self, request: OrderRequest, *, venue: str, account: Account, + risk_reference_price: Decimal | None, caller_supplied_order_id: bool, + ) -> None: + """Reject and explain a failed risk check before any venue intent is written.""" + client_order_id = request.client_order_id + decision = self.evaluate_order_risk( + request, venue=venue, account=account, risk_reference_price=risk_reference_price, + caller_supplied_order_id=caller_supplied_order_id, + ) if not decision.ok: await self.bus.publish("risk.rejected", { "rule": decision.rule, "message": decision.message, "client_order_id": client_order_id, @@ -290,27 +536,47 @@ async def submit(self, request: OrderRequest, *, venue: str | None = None, mark: }, key=client_order_id) raise RiskRejected(decision.message, rule=decision.rule) + async def _send_venue_order_with_journaled_intent( + self, request: OrderRequest, *, venue: str, account: Account, + adapter: TradingExchange, precision: Precision | None, + ) -> Order: + """Persist the intent before sending; leave unanswered sends for reconciliation. + + Keep the sending transition and venue call under the same dispatch lock. + A timeout cannot prove rejection, so it must never trigger a fresh send. + """ + client_order_id = request.client_order_id await self.journal.record_intent(request, client_order_id=client_order_id, venue=venue, account=account) async with self._lock(f"{venue}:{client_order_id}"): await self.journal.mark_intent(client_order_id, IntentState.SENDING, bump_attempt=True) - self.risk.record_sent(request) + self.order_risk_checks.record_sent(request) try: - order = await adapter.create_order(request) + order = (await adapter.create_order_with_precision(request, precision) + if precision is not None else await adapter.create_order(request)) except OrderRejected as exc: await self.journal.mark_intent(client_order_id, IntentState.REJECTED, detail=str(exc)) await self.bus.publish("order.rejected", {"client_order_id": client_order_id, "error": str(exc)}, key=client_order_id) raise except (NetworkError, asyncio.TimeoutError) as exc: # In doubt on purpose: the intent stays `sending` so the sweep - # asks the venue rather than this process guessing. + # asks the venue rather than this process guessing. The caller + # gets the in-doubt error with the id to retry under. await self.bus.publish("order.in_doubt", { "client_order_id": client_order_id, "venue": venue, "error": f"{type(exc).__name__}: {exc}", }, key=client_order_id) - raise + raise SubmissionInDoubt( + f"{venue}: no answer to the order send ({type(exc).__name__}: {exc}); it may have been " + f"accepted. Retry with client_order_id {client_order_id!r}: the venue is checked first " + "and the order is never sent twice", client_order_id=client_order_id) from exc except ExchangeError as exc: await self.journal.mark_intent(client_order_id, IntentState.FAILED, detail=str(exc)) await self.bus.publish("order.failed", {"client_order_id": client_order_id, "error": str(exc)}, key=client_order_id) raise + return order + + async def _record_accepted_venue_order(self, order: Order, request: OrderRequest, *, venue: str) -> Order: + """Save the venue response before marking its intent sent and announcing acceptance.""" + client_order_id = request.client_order_id order = self._stamp(order, request) await self.journal.upsert_order(order, event="order.accepted") self._remember(order) @@ -318,31 +584,122 @@ async def submit(self, request: OrderRequest, *, venue: str | None = None, mark: await self.bus.publish("order.accepted", order.model_dump(mode="json"), key=f"{venue}:{order.id}", persist=False) return order - async def _submit_bucket(self, request: OrderRequest, *, mark: Decimal | None) -> Order: - """An order on a bucket is a market order the engine holds as a - `routed_limit` parent, with the bucket's definition on the request so - the parent is whole in the journal. Its `price` is the worst price the - caller accepts, in bucket terms, as on any market order here. The - nominal venue is the first member's; the legs each carry their own.""" + async def _submit_bucket(self, request: OrderRequest, *, risk_reference_price: Decimal | None, + authorize_execution_accounts: Callable[[dict[str, Account]], None] | None = None) -> Order: + """Capture, authorize and persist one cross-venue instruction. + + The mutable bucket is read once. A reservation retains the captured + bucket/account scope if acceptance is interrupted before parent creation. + Retrying an accepted client ID returns its original parent. + """ if request.type != OrderType.MARKET: - raise BadRequest("an order on a bucket is a market order: set type to market, " - "and price to the worst price you accept") + raise BadRequest("an order on a bucket is a market order: set type to market") if request.price is None: - raise BadRequest("a market order on a bucket needs price: the worst price you accept, in bucket terms") - row = await self.journal.bucket(bucket_id_of(request.market_id)) - if row is None: - raise BadRequest(f"{request.market_id}: no such bucket in this journal") - bucket = Bucket.model_validate(row) - if bucket.status != "active": - raise BadRequest(f"{request.market_id}: the bucket is {bucket.status}") - missing = bucket.venues() - set(self.adapters) - if missing: - raise BadRequest(f"{request.market_id}: no adapter configured for {', '.join(sorted(missing))}") - nominal = ids.venue_of(bucket.members[0].market_id) - account = request.account or self.accounts.get(nominal) or Account(venue=nominal) - params = {**request.params, "managed_kind": "routed_limit", "bucket": bucket.model_dump(mode="json")} - request = request.model_copy(update={"params": params, "account": account}) - return await self.submit_managed(request, venue=nominal, account=account, mark=mark) + raise BadRequest("a market order on a bucket needs price: the worst price you accept") + if request.account is not None: + raise BadRequest("a bucket parent has no singular account; configure each venue's execution account") + request = request.model_copy(update={"client_order_id": request.client_order_id or f"sp-{uuid.uuid4().hex[:20]}"}) + async with self._lock(f"submission:{request.client_order_id}"): + previous = await self.journal.submission(request.client_order_id) + if previous is not None: + if previous["request"] != request_identity(request): + raise BadRequest("client_order_id already identifies a different instruction") + effective = OrderRequest.stored(previous["effective_request"]) + accounts = validate_execution_accounts({venue: Account.model_validate(account) + for venue, account in effective.params["execution_accounts"].items()}) + if authorize_execution_accounts: + authorize_execution_accounts(accounts) + result = await self._existing_submission_result(previous) + if result is not None: + return result + else: + if await self.journal.order_by_client_id(request.client_order_id) is not None: + raise BadRequest("client_order_id belongs to an older bucket order without a verifiable instruction; look it up by parent ID") + row = await self.journal.bucket(bucket_id_of(request.market_id)) + if row is None: + raise BucketNotFound(f"{request.market_id}: no such bucket in this journal") + bucket = Bucket.model_validate(row) + if bucket.status != "active": + raise BadRequest(f"{request.market_id}: the bucket is {bucket.status}") + bucket = bucket.routable() + missing = bucket.venues() - set(self.adapters) + if missing: + raise BadRequest(f"no adapter configured for {', '.join(sorted(missing))}") + accounts = validate_execution_accounts({venue: self.accounts.get(venue) or Account(venue=venue) + for venue in bucket.venues()}) + if authorize_execution_accounts: + authorize_execution_accounts(accounts) + params = {**request.params, "managed_kind": "routed_limit", "bucket": bucket.model_dump(mode="json"), + "execution_accounts": {venue: account.model_dump(mode="json") for venue, account in accounts.items()}} + effective = request.model_copy(update={"params": params}) + await self.journal.record_submission(request, effective, venue=MANAGED_NAMESPACE, account=None) + try: + bucket = Bucket.model_validate(effective.params["bucket"]) + self._evaluate_bucket_risk(effective, bucket, accounts) + return await self.submit_managed(effective, venue=None, account=None, + execution_accounts=accounts, risk_reference_price=risk_reference_price) + except (RiskRejected, BadRequest) as error: + await self.journal.reject_submission(request.client_order_id, + {"kind": type(error).__name__, "message": str(error), "rule": getattr(error, "rule", None)}) + raise + + def _evaluate_bucket_risk(self, request: OrderRequest, bucket: Bucket, accounts: dict[str, Account]) -> None: + """Check the instruction once, then full capacity in each possible destination. + + Destinations are alternatives, not summed exposure. Only actual venue + children count as resting venue orders; parent projections are not orders + at an exchange. No capacity is reserved by these admission checks. + """ + decision = self.order_risk_checks.evaluate_order_risk(request, venue=None, account=None, instruction_only=True) + if not decision.ok: + raise RiskRejected(decision.message, rule=decision.rule) + resting = [order for order in self.open_orders() if order.held_by == HeldBy.VENUE] + for member in sorted(bucket.members, key=lambda item: item.market_id): + venue = ids.venue_of(member.market_id) + self.assert_execution_account(venue, accounts[venue]) + outcome = request.outcome + if member.flip and outcome is not None: + outcome = "no" if outcome == "yes" else "yes" + projected = request.model_copy(update={"market_id": member.market_id, + "side": (Side.SELL if request.side == Side.BUY else Side.BUY) if member.flip else request.side, + "price": Decimal("1") - request.price if member.flip else request.price, + "outcome": outcome, "account": accounts[venue], "tags": {**request.tags, "parent": "admission"}}) + decision = self.order_risk_checks.evaluate_order_risk(projected, venue=venue, account=accounts[venue], + open_orders=[order for order in resting if order.venue == venue], + market_close_ts=self.market_close.get(member.market_id), event_id=self.event_of.get(member.market_id), + venue_notional=sum((abs(state.contracts) for (key, _, _), state in self.ledger.positions.items() + if key == accounts[venue].key), ZERO)) + if not decision.ok: + raise RiskRejected(f"{venue}/{accounts[venue].key}: {decision.message}", rule=decision.rule) + + def assert_execution_account(self, venue: str, account: Account) -> None: + """Refuse account-bound I/O when a saved routed account no longer matches. + + The engine supports one configured execution identity per venue. It + cannot reconcile or cancel the old account through replacement credentials. + """ + configured = self.accounts.get(venue) or Account(venue=venue) + adapter_account = getattr(self.adapters.get(venue), "account", None) + if (account.venue != venue or account != configured + or (isinstance(adapter_account, Account) and adapter_account != account)): + raise SubmissionPending(f"{venue}: saved execution account {account.key} differs from configured " + f"{configured.key}; restore the original account before recovery or execution") + + async def guard_routed_order_account(self, order: Order) -> None: + parent_id = order.parent_id or order.tags.get("parent") + if parent_id: + stored = await self.journal.managed_one(parent_id) + if stored and stored[0] == "routed_limit": + if order.account is None: + raise SubmissionPending("routed child has no persisted execution account") + self.assert_execution_account(order.venue, order.account) + + async def guard_routed_venue_accounts(self, venue: str) -> None: + for saved_key in await self.journal.routed_account_keys(venue): + configured = self.accounts.get(venue) or Account(venue=venue) + if saved_key != configured.key: + raise SubmissionPending(f"{venue}: saved routed account {saved_key} differs from configured {configured.key}") + self.assert_execution_account(venue, configured) async def save_bucket(self, bucket: Bucket) -> Bucket: """Store or update a bucket definition in this engine's journal.""" @@ -350,18 +707,23 @@ async def save_bucket(self, bucket: Bucket) -> Bucket: await self.journal.save_bucket(bucket.model_dump(mode="json")) return bucket - async def submit_managed(self, request: OrderRequest, *, venue: str, account: Account, - mark: Decimal | None = None) -> Order: + async def submit_managed(self, request: OrderRequest, *, venue: str | None, account: Account | None, + execution_accounts: dict[str, Account] | None = None, + risk_reference_price: Decimal | None = None) -> Order: """Accept an engine-held order: check it, journal it, start it.""" check_managed_fields(request, now_ms=int(self.clock() * 1000)) - decision = self.check(request, venue=venue, account=account, mark=mark) + decision = (Decision(ok=True) if execution_accounts else self.evaluate_order_risk( + request, venue=venue, account=account, risk_reference_price=risk_reference_price, + )) if not decision.ok: await self.bus.publish("risk.rejected", { "rule": decision.rule, "message": decision.message, "market_id": request.market_id, "book": request.book, "type": request.type.value, "config_version": decision.config_version, }) raise RiskRejected(decision.message, rule=decision.rule) - parent = await self.orders.create(request, venue=venue, account=account) + request = request.model_copy(update={"user_id": request.user_id or self.config.user_id, + "client_order_id": request.client_order_id or f"sp-{uuid.uuid4().hex[:20]}"}) + parent = await self.managed_order_controller.create(request, venue=venue, account=account, execution_accounts=execution_accounts) return parent.as_order() async def submit_child(self, parent: Any, request: OrderRequest, **kw: Any) -> Order: @@ -373,16 +735,22 @@ async def submit_child(self, parent: Any, request: OrderRequest, **kw: Any) -> O no venue falls back to the parent's, which is every single-venue type.""" venue = self._child_venue(parent, request) account = parent.account - if venue != parent.venue: + if parent.execution_accounts: + account = parent.execution_accounts.get(venue) + if account is None: + raise BadRequest(f"{venue}: child is outside the saved execution scope") + self.assert_execution_account(venue, account) + request = request.model_copy(update={"account": account}) + elif venue != parent.venue: # `child_request` stamps the parent's account on every child; a # leg on another venue must carry that venue's instead. account = self.accounts.get(venue) or Account(venue=venue) request = request.model_copy(update={"account": account}) if request.type in VENUE_ORDER_TYPES: order = await self.submit(request, venue=venue, **kw) - self.orders.adopt_child(parent.id, order.id, order.venue) + self.managed_order_controller.adopt_child(parent.id, order.id, order.venue) return order - leg = await self.orders.create(request, venue=venue, account=account, owner=parent) + leg = await self.managed_order_controller.create(request, venue=venue, account=account, owner=parent) return leg.as_order() @staticmethod @@ -390,38 +758,60 @@ def _child_venue(parent: Any, request: OrderRequest) -> str: try: return ids.venue_of(request.market_id) except BadRequest: + if parent.venue is None: + raise BadRequest("a routed child must name its concrete venue") from None return parent.venue async def cancel_child(self, parent: Any, order_id: str, venue: str | None = None) -> Order | None: - if order_id in self.orders.parents: - return (await self.orders.cancel(order_id, reason=f"cancelled by {parent.id}")).as_order() + """Cancel a managed child through its controller, or a venue child through cancel().""" + if order_id in self.managed_order_controller.parents: + return (await self.managed_order_controller.cancel(order_id, reason=f"cancelled by {parent.id}")).as_order() child = parent.child_of(order_id, venue) venue = venue or (child.venue if child is not None else parent.venue) try: return await self.cancel(order_id, venue=venue) + except CancelUnconfirmed: + # The venue knows nothing of it: the child stays live and the + # parent pending, retried by its timer, never assumed cancelled. + raise except OrderNotFound: return None - def check(self, request: OrderRequest, *, venue: str, account: Account, mark: Decimal | None = None, - deliberate: bool = False) -> Decision: - """The risk decision on its own, for a caller that wants to ask first.""" + def evaluate_order_risk( + self, request: OrderRequest, *, venue: str, account: Account, + risk_reference_price: Decimal | None = None, + caller_supplied_order_id: bool = False, + ) -> Decision: + """Return whether an order passes the configured risk rules, without submitting it.""" market_id = request.market_id - if mark is None: - mark = self.fair_values.get(account.key, market_id) - return self.risk.check( + if risk_reference_price is None: + risk_reference_price = self.book_mid(market_id) + return self.order_risk_checks.evaluate_order_risk( request, venue=venue, account=account, - open_orders=[o for o in self.open_orders(venue=venue)], - mark=mark, market_close_ts=self.market_close.get(market_id), event_id=self.event_of.get(market_id), - deliberate=deliberate, + open_orders=[o for o in self.open_orders(venue=venue) if not o.execution_accounts], + risk_reference_price=risk_reference_price, market_close_ts=self.market_close.get(market_id), + event_id=self.event_of.get(market_id), + caller_supplied_order_id=caller_supplied_order_id, ) async def cancel(self, order_id: str, *, venue: str | None = None, market_id: str | None = None) -> Order: """Cancel, journaled with its own idempotency key. An engine-held order - pulls its children first.""" - if order_id in self.orders.parents: - parent = await self.orders.cancel(order_id) + pulls its children first. + + Only the venue confirms a cancellation. An order already finished is + returned as it is, without a venue call; a venue that answers "not + found" is asked for the order itself, so a fill that won the race is + recorded as `closed` rather than overwritten as `canceled`.""" + if order_id in self.managed_order_controller.parents: + parent = await self.managed_order_controller.cancel(order_id) return parent.as_order() order = await self._known(order_id, venue) + if order.is_terminal or order.held_by == HeldBy.ENGINE: + # Nothing at a venue to cancel: a finished venue order, or an + # engine-held parent this process no longer runs because it + # finished before a restart (live parents are always restored). + return order + await self.guard_routed_order_account(order) venue = venue or order.venue adapter = self._adapter(venue) key = f"cancel:{venue}:{order_id}" @@ -430,6 +820,7 @@ async def cancel(self, order_id: str, *, venue: str | None = None, market_id: st price=order.price, book=order.book, trader=order.trader, client_order_id=key, ) account = order.account or self.accounts.get(venue) or Account(venue=venue) + request = request.model_copy(update={"account": account, "tags": dict(order.tags)}) await self.journal.record_intent(request, client_order_id=key, venue=venue, account=account, operation="cancel", target_order_id=order_id) async with self._lock(key): @@ -437,26 +828,51 @@ async def cancel(self, order_id: str, *, venue: str | None = None, market_id: st try: result = await adapter.cancel_order(order_id, market_id=market_id or order.market_id) except OrderNotFound: - # Already gone: the cancel achieved what it asked for. - await self.journal.mark_intent(key, IntentState.SETTLED, detail="already gone") - closed = order.model_copy(update={"status": OrderStatus.CANCELED}) - await self.journal.upsert_order(closed, event="order.canceled") - self._remember(closed) - return closed + return await self._record_order_the_venue_would_not_cancel(adapter, order, key) except (NetworkError, asyncio.TimeoutError): raise result = self._stamp(result, None, template=order) await self.journal.upsert_order(result, event="order.canceled") self._remember(result) await self.journal.mark_intent(key, IntentState.SETTLED, order_id=order_id) - await self.orders.on_order(result) + await self.managed_order_controller.on_order(result) await self.bus.publish("order.canceled", result.model_dump(mode="json"), key=f"{venue}:{order_id}", persist=False) return result + async def _record_order_the_venue_would_not_cancel(self, adapter: TradingExchange, order: Order, + cancel_key: str) -> Order: + """The venue has no resting order to cancel. That is not a + cancellation: the order may have filled, expired, or been cancelled + elsewhere. Read the order itself and record the venue's verdict, with + whatever filled, through the same path as any order update, so an + engine-held parent hears about its child. A venue with no record at + all confirms nothing: the last observed status stands.""" + try: + observed = await adapter.fetch_order(order.id) + except OrderNotFound: + detail = "the venue has no record of the order; cancellation unconfirmed" + await self.journal.mark_intent(cancel_key, IntentState.FAILED, detail=detail) + raise CancelUnconfirmed( + f"{order.venue} has no record of order {order.id}: cancellation unconfirmed; " + f"its last observed status ({order.status.value}) stands") from None + observed = self._stamp(observed, None, template=order) + if observed.is_terminal: + await self.journal.mark_intent(cancel_key, IntentState.SETTLED, order_id=order.id, + detail=f"already {observed.status.value} at the venue") + else: + await self.journal.mark_intent(cancel_key, IntentState.FAILED, + detail=f"the venue refused the cancel and reports the order " + f"{observed.status.value}") + await self.on_order(observed) + return await self.journal.order(order.venue, order.id) or observed + async def edit(self, request: EditRequest, *, venue: str | None = None) -> Order: """Amend a resting order. The edit carries its own idempotency key, so a retry is the same edit rather than a second one.""" order = await self._known(request.order_id, venue) + if order.held_by == HeldBy.ENGINE: + raise BadRequest("engine-held parents cannot be amended; cancel and submit a new instruction") + await self.guard_routed_order_account(order) venue = venue or order.venue adapter = self._adapter(venue) key = request.client_order_id or f"edit:{venue}:{request.order_id}:{uuid.uuid4().hex[:8]}" @@ -467,12 +883,13 @@ async def edit(self, request: EditRequest, *, venue: str | None = None) -> Order client_order_id=key, ) account = order.account or self.accounts.get(venue) or Account(venue=venue) - decision = self.check(intent_request, venue=venue, account=account) + decision = self.evaluate_order_risk(intent_request, venue=venue, account=account) if not decision.ok: await self.bus.publish("risk.rejected", { "rule": decision.rule, "message": decision.message, "order_id": order.id, "operation": "edit", }, key=key) raise RiskRejected(decision.message, rule=decision.rule) + intent_request = intent_request.model_copy(update={"account": account, "tags": dict(order.tags)}) await self.journal.record_intent(intent_request, client_order_id=key, venue=venue, account=account, operation="edit", target_order_id=order.id) async with self._lock(key): @@ -482,22 +899,85 @@ async def edit(self, request: EditRequest, *, venue: str | None = None) -> Order await self.journal.upsert_order(result, event="order.edited") self._remember(result) await self.journal.mark_intent(key, IntentState.SETTLED, order_id=result.id) - await self.orders.on_order(result) + await self.managed_order_controller.on_order(result) await self.bus.publish("order.edited", result.model_dump(mode="json"), key=f"{venue}:{result.id}", persist=False) return result + # -- RFQ and combos (docs/rfq-combo.md; the work is in `engine.rfq`) ------- + + async def create_rfq(self, instrument_id: str, **kw: Any) -> Any: + """Ask for quotes on a market or a combo. See `RfqDesk.create_rfq`.""" + return await self.rfq.create_rfq(instrument_id, **kw) + + async def cancel_rfq(self, rfq_id: str, *, venue: str | None = None) -> None: + await self.rfq.cancel_rfq(rfq_id, venue=venue) + + async def accept_quote(self, accept: Any, *, venue: str | None = None, wait: bool = False, + timeout_s: float = 30.0) -> Any: + """Take one side of a quote: risk, intent, venue, then events. Returns + at once with the quote state unless `wait`. See `RfqDesk.accept_quote`.""" + return await self.rfq.accept_quote(accept, venue=venue, wait=wait, timeout_s=timeout_s) + + async def send_quote(self, quote: Any, *, venue: str | None = None) -> Any: + return await self.rfq.send_quote(quote, venue=venue) + + async def replace_quote(self, quote_id: str, quote: Any, *, venue: str | None = None) -> Any: + return await self.rfq.replace_quote(quote_id, quote, venue=venue) + + async def cancel_quote(self, quote_id: str, *, rfq_id: str | None = None, venue: str | None = None) -> None: + await self.rfq.cancel_quote(quote_id, rfq_id=rfq_id, venue=venue) + + async def confirm_quote(self, quote: Any, *, rfq_id: str | None = None, venue: str | None = None, + wait: bool = False, timeout_s: float = 30.0) -> Any: + """The maker's last look, through risk and the journal. See `RfqDesk.confirm_quote`.""" + return await self.rfq.confirm_quote(quote, rfq_id=rfq_id, venue=venue, wait=wait, timeout_s=timeout_s) + + async def create_combo(self, legs: list[Any], *, venue: str | None = None, + collection_id: str | None = None) -> Any: + """The combo for these legs; `collection_id` names the collection on a + venue that builds combos in one (Kalshi). See `RfqDesk.create_combo`.""" + return await self.rfq.create_combo(legs, venue=venue, collection_id=collection_id) + + # -- cross-venue RFQ (docs/rfq-combo.md, "Cross-venue RFQ"; `engine.rfq_groups`) -- + + async def create_rfq_group(self, instrument_id: str | None = None, **kw: Any) -> Any: + """One RFQ on every venue that lists the instrument (a market with + `cross_venue=True`, a bucket, or a combo's legs), ranked as one. See + `RfqGroupDesk.create_group`.""" + return await self.rfq_groups.create_group(instrument_id, **kw) + + async def rfq_group(self, group_id: str) -> Any: + return await self.rfq_groups.get(group_id) + + async def preview_rfq_group(self, instrument_id: str | None = None, **kw: Any) -> Any: + """Which venues `create_rfq_group` would ask, and why it would skip + the others, without creating anything. See `RfqGroupDesk.preview`.""" + return await self.rfq_groups.preview(instrument_id, **kw) + + async def accept_group_quote(self, group_id: str, quote_id: str, **kw: Any) -> Any: + """Take one quote of a group: the claim, every other member cancelled, + then the accept. See `RfqGroupDesk.accept_group_quote`.""" + return await self.rfq_groups.accept_group_quote(group_id, quote_id, **kw) + + async def cancel_rfq_group(self, group_id: str, *, reason: str = "canceled by the caller") -> Any: + return await self.rfq_groups.cancel_group(group_id, reason=reason) + # -- halting -------------------------------------------------------------- async def halt(self, reason: str, *, scope: str = "*", policy: HaltPolicy | None = None, rearm_after_s: float | None = None) -> dict[str, Any]: """Stop trading. Returns what the policy did, per venue.""" policy = policy or self.config.halt_policy - self.risk.kill.engage(reason=reason, scope=scope, policy=policy, rearm_after_s=rearm_after_s) + self.order_risk_checks.kill.engage(reason=reason, scope=scope, policy=policy, rearm_after_s=rearm_after_s) result: dict[str, Any] = {"policy": policy, "scope": scope, "reason": reason, "canceled": {}, "remaining": {}} if policy == "cancel": + # Offers first: an RFQ or a quote left up could be accepted while + # the orders are being pulled (docs/rfq-combo.md §5). + result["rfq"] = await self.rfq.on_halt(scope) for venue, adapter in self.adapters.items(): if scope not in ("*", venue): continue try: + await self.guard_routed_venue_accounts(venue) count = await adapter.cancel_all_orders() result["canceled"][venue] = count except Exception as exc: # a venue that cannot be reached must not stop the others @@ -507,11 +987,12 @@ async def halt(self, reason: str, *, scope: str = "*", policy: HaltPolicy | None if left: result["remaining"][venue] = left for order in self.open_orders(): - if scope in ("*", order.venue): + if (order.held_by == HeldBy.VENUE and scope in ("*", order.venue) + and not isinstance(result["canceled"].get(order.venue), str)): canceled = order.model_copy(update={"status": OrderStatus.CANCELED}) await self.journal.upsert_order(canceled, event="order.canceled") self._remember(canceled) - result["managed"] = await self.orders.on_halt(reason, scope=scope) + result["managed"] = await self.managed_order_controller.on_halt(reason, scope=scope) await self.bus.publish("engine.halted", result) return result @@ -539,21 +1020,21 @@ async def _finish_cancelling(self, venue: str, adapter: TradingExchange, *, roun return -1 async def resume(self, *, scope: str | None = None) -> None: - self.risk.kill.release(scope=scope) + self.order_risk_checks.kill.release(scope=scope) await self.bus.publish("engine.resumed", {"scope": scope or "*"}) async def pause_book(self, book: str, *, paused: bool = True) -> None: """Stop one strategy without stopping the engine.""" - books = set(self.risk.config.paused_books) + books = set(self.order_risk_checks.config.paused_books) books.add(book) if paused else books.discard(book) - self.risk.config = self.risk.config.model_copy(update={"paused_books": sorted(books)}) - self.risk.config_version = await self.journal.save_config("risk", self.risk.config.model_dump(mode="json")) + self.order_risk_checks.config = self.order_risk_checks.config.model_copy(update={"paused_books": sorted(books)}) + self.order_risk_checks.config_version = await self.journal.save_config("risk", self.order_risk_checks.config.model_dump(mode="json")) await self.bus.publish("engine.book_paused" if paused else "engine.book_resumed", {"book": book}) async def set_risk(self, config: RiskConfig, *, author: str | None = None) -> int: """Replace the rules; the new version is journaled and returned.""" version = await self.journal.save_config("risk", config.model_dump(mode="json"), author=author) - self.risk.configure(config, version=version) + self.order_risk_checks.configure(config, version=version) await self.bus.publish("risk.configured", {"version": version, "author": author}) return version @@ -565,7 +1046,9 @@ async def recover(self) -> Recovery: for fill in await self.journal.fills(): self.ledger.apply_fill(fill) recovery.fills_replayed += 1 - self.risk.roll_day() + self.order_risk_checks.roll_day() + await self.rfq.restore() + await self.rfq_groups.restore() for intent in await self.journal.in_doubt(): recovery.in_doubt += 1 outcome = await self._resolve(intent) @@ -577,10 +1060,14 @@ async def recover(self) -> Recovery: else: # Pending or unreachable: still in doubt, and reported as such. recovery.unresolved += 1 - recovery.managed = await self.orders.restore() + # After the in-doubt pass, so a member create or accept resolved a + # moment ago is what its group sees. + await self.rfq_groups.reconcile_all() + recovery.managed = await self.managed_order_controller.restore() # After the in-doubt intents, so an order adopted a moment ago is in # the cache the risk rules count against. recovery.orders_open = len(await self.refresh_open_orders()) + await self.managed_order_controller.resume_restored() return recovery async def sweep(self) -> list[dict[str, Any]]: @@ -592,17 +1079,32 @@ async def sweep(self) -> list[dict[str, Any]]: async def _resolve(self, intent: Any) -> dict[str, Any]: """Ask the venue whether this intent's order exists.""" + if intent.operation in RFQ_OPERATIONS: + return await self.rfq.resolve(intent) adapter = self.adapters.get(intent.venue) base = {"client_order_id": intent.client_order_id, "venue": intent.venue, "operation": intent.operation} if adapter is None: await self.bus.publish("intent.unresolved", base | {"reason": "no adapter for this venue"}) return base | {"result": "unresolved", "reason": "no adapter"} try: + persisted = OrderRequest.stored(intent.request) + parent_id = persisted.tags.get("parent") + stored = await self.journal.managed_one(parent_id) if parent_id else None + if stored and stored[0] == "routed_limit": + if persisted.account is None: + raise SubmissionPending("routed intent has no saved execution account") + self.assert_execution_account(intent.venue, persisted.account) + if intent.target_order_id: + target = await self.journal.order(intent.venue, intent.target_order_id) + if target is not None: + await self.guard_routed_order_account(target) found = await self._find_by_client_id(adapter, intent) except Exception as exc: await self.bus.publish("intent.unresolved", base | {"reason": f"{type(exc).__name__}: {exc}"}) return base | {"result": "unresolved", "reason": str(exc)} if found is not None: + if intent.operation == "create": + found = self._stamp(found, OrderRequest.stored(intent.request)) await self.journal.upsert_order(found, event="order.adopted") self._remember(found) await self.journal.mark_intent(intent.client_order_id, IntentState.SENT, order_id=found.id, @@ -630,12 +1132,22 @@ async def _find_by_client_id(self, adapter: TradingExchange, intent: Any) -> Ord for order in await adapter.fetch_open_orders(): if order.client_order_id == intent.client_order_id: return order - if adapter.has.get("fetch_orders"): - since = intent.created_ts - 60_000 - page = await adapter.fetch_orders(since=since, limit=200) + if not adapter.has.get("fetch_orders"): + raise SubmissionPending("the venue cannot confirm historical order absence") + since = intent.created_ts - 60_000 + cursor = None + seen = set() + while True: + page = await adapter.fetch_orders(since=since, limit=200, cursor=cursor) for order in page: if order.client_order_id == intent.client_order_id: return order + cursor = getattr(page, "next_cursor", None) + if not cursor: + break + if cursor in seen: + raise SubmissionPending("the venue repeated its order-history cursor") + seen.add(cursor) return None # -- state ---------------------------------------------------------------- @@ -643,7 +1155,7 @@ async def _find_by_client_id(self, adapter: TradingExchange, intent: Any) -> Ord def open_orders(self, *, venue: str | None = None, book: str | None = None) -> list[Order]: """Open orders as the engine believes them, from memory of the journal.""" return [o for o in self._open_cache.values() - if (venue is None or o.venue == venue) and (book is None or o.book == book)] + if (venue is None or o.venue == venue or venue in o.execution_accounts) and (book is None or o.book == book)] async def refresh_open_orders(self) -> list[Order]: orders = await self.journal.open_orders() @@ -654,16 +1166,28 @@ async def on_order(self, order: Order) -> None: """Record an order update, from a stream or a poll.""" stored = await self.journal.order(order.venue, order.id) if stored is not None: + if order.filled < stored.filled or (stored.is_terminal and not order.is_terminal and order.filled == stored.filled): + return order = self._stamp(order, None, template=stored) + elif order.client_order_id: + intent = await self.journal.intent(order.client_order_id) + if intent is not None and intent.operation in ("create", *FOLLOWED): + order = self._stamp(order, OrderRequest.stored(intent.request)) await self.journal.upsert_order(order) self._remember(order) await self.bus.publish(f"order.{order.status.value}", order.model_dump(mode="json"), key=f"{order.venue}:{order.id}", persist=False) - await self.orders.on_order(order) + await self.managed_order_controller.on_order(order) async def on_fill(self, fill: Fill) -> None: - """Record a fill once, book it in the ledger, publish it.""" + """Record a fill once, book it in the ledger, publish it. A fill of a + known order carries that order's outcome and action.""" order = await self.journal.order(fill.venue, fill.order_id) + if order is None and self.rfq.following: + # Maybe the order an executed quote made, not adopted yet. + order = await self.rfq.claim_fill(fill) + if order is not None and order.outcome is not None and fill.outcome is None: + fill = fill.model_copy(update={"outcome": order.outcome, "action": order.action}) book = order.book if order else None trader = order.trader if order else None changed = await self.journal.record_fill(fill, book=book, trader=trader) @@ -684,7 +1208,7 @@ async def on_book(self, event: Any) -> None: book = self.books.get(market_id) if book is not None: self.books[market_id] = book - await self.orders.on_book(market_id) + await self.managed_order_controller.on_book(market_id) def set_book(self, market_id: str, book: Any) -> None: """Hand the engine a `LocalBook` (or anything with `best_bid`, @@ -696,20 +1220,76 @@ async def on_trade(self, event: Any) -> None: market_id = getattr(event, "market_id", None) or event["market_id"] price = getattr(event, "price", None) or event["price"] quantity = getattr(event, "quantity", None) or event.get("quantity", ZERO) + timestamp = getattr(event, "timestamp", None) + if isinstance(event, dict): + timestamp = event.get("timestamp") + if timestamp is not None: + timestamp = int(timestamp) + minimum = max(self.started_ts or 0, self.last_trade_timestamps.get(market_id, 0)) + if timestamp < minimum: + return + self.last_trade_timestamps[market_id] = timestamp self.last_trade[market_id] = Decimal(str(price)) - await self.orders.on_trade(market_id, Decimal(str(price)), Decimal(str(quantity))) + await self.managed_order_controller.on_trade(market_id, Decimal(str(price)), Decimal(str(quantity))) async def on_settlement(self, settlement: Settlement) -> None: realized = self.ledger.apply_settlement(settlement) await self.bus.publish("settlement.booked", settlement.model_dump(mode="json") | {"realized": str(realized)}, key=f"{settlement.venue}:{settlement.market_id}") + def marks(self) -> dict[tuple[str, str, str], Decimal]: + """What every open position is marked at, keyed `(account_key, book, + market_id)` as `Ledger.rollup` wants: the price it could be closed at + now (`exit_price`). Read from the live book each time, so a mark never + outlives the book it came from.""" + marks: dict[tuple[str, str, str], Decimal] = {} + for key, state in self.ledger.positions.items(): + mark = self.exit_price(state.market_id, state.contracts) + if mark is not None: + marks[key] = mark + return marks + + def exit_price(self, market_id: str, contracts: Decimal) -> Decimal | None: + """The YES price a position of `contracts` (signed, YES terms) could be + closed at: on the engine's ready, uncrossed book, a long YES sells YES + at the best bid and a short YES, which is a long NO, buys YES back at + the best ask (sells NO at 1 − ask). With no such book, or nobody on + that side, the last trade the engine saw. `None` when flat or with + neither.""" + if contracts == 0: + return None + book = self.books.get(market_id) + if book is not None and getattr(book, "ready", True) is not False: + bid, ask = book.best_bid, book.best_ask + if not (bid is not None and ask is not None and bid > ask): + touch = bid if contracts > 0 else ask + if touch is not None: + return touch + return self.last_trade.get(market_id) + + def book_mid(self, market_id: str) -> Decimal | None: + """The midpoint of the engine's ready, uncrossed book for a market, + with both sides quoted; `None` otherwise. The risk collar's reference.""" + book = self.books.get(market_id) + if book is None or getattr(book, "ready", True) is False: + return None + bid, ask = book.best_bid, book.best_ask + if bid is None or ask is None or bid > ask: + return None + return (bid + ask) / 2 + def positions(self) -> list[Position]: - marks = self.fair_values.marks() + """Every position the ledger holds. A combo is its own instrument; its + legs ride along in `info["combo"]` for display.""" + marks = self.marks() out = [] - for state in self.ledger.positions.values(): - mark = marks.get((state.account_key, state.market_id)) - out.append(state.to_position(self.accounts.get(state.venue), mark=mark)) + for key, state in self.ledger.positions.items(): + position = state.to_position(self.accounts.get(state.venue), mark=marks.get(key)) + combo = self.rfq.combos.get(state.market_id) + if combo is not None: + position.info["combo"] = {"collection_id": combo.collection_id, + "legs": [leg.model_dump(mode="json", exclude={"info"}) for leg in combo.legs]} + out.append(position) return out async def bucket_orders(self, bucket_id: str) -> list[Any]: @@ -718,14 +1298,14 @@ async def bucket_orders(self, bucket_id: str) -> list[Any]: holds is rebuilt from its journal snapshot, read-only.""" out = [] for kind, snapshot in await self.journal.managed_on(f"bucket:{bucket_id}"): - parent = self.orders.get(snapshot["id"]) or self._rebuilt(kind, snapshot) + parent = self.managed_order_controller.get(snapshot["id"]) or self._rebuilt(kind, snapshot) if parent is not None: out.append(parent) return out async def bucket_order(self, parent_id: str) -> Any | None: """One order on a bucket, by its id, or None if the id is not one.""" - parent = self.orders.get(parent_id) + parent = self.managed_order_controller.get(parent_id) if parent is None: found = await self.journal.managed_one(parent_id) parent = self._rebuilt(*found) if found else None @@ -744,7 +1324,7 @@ async def bucket_position(self, bucket_id: str, *, book: str | None = None) -> d if row is None: raise BadRequest(f"bucket:{bucket_id}: no such bucket in this journal") bucket = Bucket.model_validate(row) - marks = self.fair_values.marks() + marks = self.marks() members: list[dict[str, Any]] = [] net = ZERO cost = ZERO @@ -761,7 +1341,7 @@ async def bucket_position(self, bucket_id: str, *, book: str | None = None) -> d cost += signed * entry realized += state.realized fees += state.fees - mark = marks.get((account_key, market_id)) + mark = marks.get((account_key, name, market_id)) members.append({ "market_id": member.market_id, "venue": state.venue, "book": name, "account": account_key, "flip": member.flip, "contracts": str(state.contracts), "bucket_contracts": str(signed), @@ -776,7 +1356,7 @@ async def bucket_position(self, bucket_id: str, *, book: str | None = None) -> d } def pnl(self, level: str = "book") -> dict[str, Any]: - marks = self.fair_values.marks() + marks = self.marks() rows = self.ledger.rollup(level, marks) # type: ignore[arg-type] total = self.ledger.total(marks) return {"level": level, "rows": rows, "total": total} @@ -793,12 +1373,36 @@ async def _lease_loop(self) -> None: await self.halt("the journal lease was taken by another engine", policy="hold") raise LeaseLost("another engine took the journal lease; this one stopped trading") + def queue_managed_action(self, parent_id: str) -> None: + """Queue a hint; the durable parent snapshot remains the source of truth.""" + if parent_id not in self._queued_parents: + self._queued_parents.add(parent_id) + self._dispatch_queue.put_nowait(parent_id) + + async def _dispatch_loop(self) -> None: + """One stop-action dispatcher keeps venue latency off the price consumer.""" + self.dispatch_running = True + try: + while self.running: + parent_id = await self._dispatch_queue.get() + self._queued_parents.discard(parent_id) + parent = self.managed_order_controller.get(parent_id) + if parent is None or not parent.live: + continue + self.dispatch_parent = parent_id + try: + await self.managed_order_controller.run(parent, lambda ctx: parent.on_timer(ctx)) + finally: + self.dispatch_parent = None + finally: + self.dispatch_running = False + async def _managed_loop(self) -> None: """The clock engine-held orders run on.""" while self.running: await asyncio.sleep(self.config.managed_tick_s) try: - await self.orders.on_timer() + await self.managed_order_controller.on_timer() except Exception: log.exception("synpath.engine: the managed-order timer failed") @@ -821,6 +1425,7 @@ async def _poll_loop(self) -> None: log.exception("synpath.engine: polling %s failed", venue) async def poll(self, venue: str, adapter: TradingExchange | None = None) -> None: + await self.guard_routed_venue_accounts(venue) adapter = adapter or self._adapter(venue) for order in await adapter.fetch_open_orders(): await self.on_order(order) @@ -856,6 +1461,11 @@ def _venue_of(self, request: OrderRequest) -> str: "use a Synpath id (venue:native), pass venue=, or name an account") async def _known(self, order_id: str, venue: str | None) -> Order: + managed = await self.journal.managed_one(order_id) + if managed is not None: + parent = self._rebuilt(*managed) + if parent is not None: + return parent.as_order() if venue is not None: order = await self.journal.order(venue, order_id) if order is not None: @@ -871,6 +1481,16 @@ def _stamp(self, order: Order, request: OrderRequest | None, *, template: Order source = request or template updates: dict[str, Any] = {} if source is not None: + updates["user_id"] = source.user_id + updates["type"] = source.type + # Only what the caller said: an order with a side alone has no + # outcome and action of its own (on a netting venue a sell may + # close YES or open NO), and a token venue reports the token. + if source.outcome is not None: + updates["outcome"], updates["action"] = source.outcome, source.action + updates["time_in_force"] = source.time_in_force + updates["expires_at"] = source.expires_at + updates["parent_id"] = getattr(source, "parent_id", None) or source.tags.get("parent") if order.book is None and source.book: updates["book"] = source.book if order.trader is None and source.trader: diff --git a/synpath/engine/eod.py b/synpath/engine/eod.py index 05ef9d3..fea87e2 100644 --- a/synpath/engine/eod.py +++ b/synpath/engine/eod.py @@ -78,13 +78,13 @@ async def run(self, *, date: str | None = None, book_settlements: bool = True) - if book_settlements: report.settlements, report.settled_realized = await self._settle() - since = engine.risk.day_start_ms + since = engine.order_risk_checks.day_start_ms for fill in await engine.journal.fills(since_ts=since): report.fills += 1 report.volume += fill.quantity report.fees += fill.fee or ZERO - marks = engine.fair_values.marks() + marks = engine.marks() for level, target in (("book", report.by_book), ("account", report.by_account)): for key, row in engine.ledger.rollup(level, marks).items(): # type: ignore[arg-type] target[key] = { @@ -106,7 +106,7 @@ async def run(self, *, date: str | None = None, book_settlements: bool = True) - report.ended_ms = int(engine.clock() * 1000) await engine.bus.publish("eod.report", report.summary(), key=day) - engine.risk.roll_day(realized_now=total.realized) + engine.order_risk_checks.roll_day(realized_now=total.realized) await engine.journal.set_cursor("eod:last", day) return report diff --git a/synpath/engine/events.py b/synpath/engine/events.py index 5f1a2e8..9bc71a5 100644 --- a/synpath/engine/events.py +++ b/synpath/engine/events.py @@ -23,7 +23,7 @@ from dataclasses import dataclass, field from typing import Any, AsyncIterator, Callable, Iterable -from .journal import Journal +from .storage import JournalStore log = logging.getLogger("synpath.engine") @@ -37,8 +37,8 @@ class EngineEvent: payload: dict[str, Any] = field(default_factory=dict) key: str | None = None ts: int = field(default_factory=lambda: int(time.time() * 1000)) + # The journal sequence number, once it has one. seq: int | None = None - """The journal sequence number, once it has one.""" def __str__(self) -> str: return f"{self.kind}({self.key})" if self.key else self.kind @@ -81,7 +81,7 @@ def close(self) -> None: class EventBus: """Publish once, persist once, fan out to every reader.""" - def __init__(self, journal: Journal | None = None, *, persist: bool = True): + def __init__(self, journal: JournalStore | None = None, *, persist: bool = True): self.journal = journal self.persist = persist and journal is not None self.subscribers: list[Subscription] = [] diff --git a/synpath/engine/execution_scope.py b/synpath/engine/execution_scope.py new file mode 100644 index 0000000..5891124 --- /dev/null +++ b/synpath/engine/execution_scope.py @@ -0,0 +1,34 @@ +"""Execution accounts and private storage identity for routed instructions. + +The managed namespace is a database key, never a venue or an Account. +Only children have a concrete execution venue; a routed parent saves the +complete set of accounts it is allowed to use. +""" +from __future__ import annotations + +from ..trading.types import Account, Order +from ..errors import BadRequest + +MANAGED_NAMESPACE = "__managed__" + + +class BucketNotFound(BadRequest): + """A missing bucket remains a bad SDK request and an HTTP 404.""" + + +def storage_identity(order: Order) -> tuple[str, str]: + if order.execution_accounts: + return MANAGED_NAMESPACE, MANAGED_NAMESPACE + return order.venue, order.account.key if order.account else f"{order.venue}:default" + + +def order_accounts(order: Order) -> list[str]: + if order.execution_accounts: + return sorted(account.key for account in order.execution_accounts.values()) + return [order.account.key if order.account else f"{order.venue}:default"] + + +def validate_execution_accounts(accounts: dict[str, Account]) -> dict[str, Account]: + if not accounts or any(venue != account.venue for venue, account in accounts.items()): + raise ValueError("execution accounts must match their concrete venues") + return dict(sorted(accounts.items())) diff --git a/synpath/engine/fair_values.py b/synpath/engine/fair_values.py deleted file mode 100644 index d939b70..0000000 --- a/synpath/engine/fair_values.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Fair values: the number the engine marks a position at. - -A mark is a decision, not a fact, so it is stored rather than computed on -the fly: per account and instrument, with where it came from and when. The -ledger marks unrealized profit with it, and later the synthetic order types -peg to it and the router compares venues with it. - -Three sources, in the order the engine prefers them: - -* one the caller set, because a desk's own model outranks anything here; -* the midpoint of a book the engine already has, which is free and honest - while the book is tight; -* the last trade, when a side of the book is empty and a midpoint would be - invented. - -A mark that nothing has refreshed goes stale. `stale_after_s` decides when, -and a stale mark is returned as `None` rather than used quietly, so a -position shows its cost instead of a profit computed from yesterday's price. -""" -from __future__ import annotations - -import time -from dataclasses import dataclass -from decimal import Decimal -from typing import Any, Iterable - -from .journal import Journal - -ZERO = Decimal("0") -ONE = Decimal("1") - - -@dataclass(frozen=True, slots=True) -class FairValue: - account_key: str - market_id: str - value: Decimal - source: str - ts: int - - def stale(self, *, after_s: float, now_ms: int | None = None) -> bool: - stamp = now_ms if now_ms is not None else int(time.time() * 1000) - return (stamp - self.ts) / 1000 > after_s - - -class FairValues: - """Marks per account and instrument, kept in memory and in the journal.""" - - def __init__(self, journal: Journal | None = None, *, stale_after_s: float = 300.0, face_value: Decimal = ONE): - self.journal = journal - self.stale_after_s = stale_after_s - self.face_value = face_value - self._values: dict[tuple[str, str], FairValue] = {} - - async def load(self) -> None: - """Read what the journal has, so a restart does not start blind.""" - if self.journal is None: - return - stamp = int(time.time() * 1000) - for (account_key, market_id), value in (await self.journal.fair_values()).items(): - self._values[(account_key, market_id)] = FairValue(account_key, market_id, value, "journal", stamp) - - async def set(self, account_key: str, market_id: str, value: Decimal, *, source: str = "manual", persist: bool = True) -> FairValue: - mark = FairValue(account_key, market_id, Decimal(value), source, int(time.time() * 1000)) - self._values[(account_key, market_id)] = mark - if persist and self.journal is not None: - await self.journal.set_fair_value(account_key, market_id, mark.value, source=source) - return mark - - def get(self, account_key: str, market_id: str) -> Decimal | None: - """The mark, or `None` if there is none or it has gone stale.""" - mark = self._values.get((account_key, market_id)) - if mark is None or mark.stale(after_s=self.stale_after_s): - return None - return mark.value - - def entry(self, account_key: str, market_id: str) -> FairValue | None: - return self._values.get((account_key, market_id)) - - def marks(self) -> dict[tuple[str, str], Decimal]: - """Every live mark, in the shape `Ledger.rollup` wants.""" - stamp = int(time.time() * 1000) - return { - key: mark.value for key, mark in self._values.items() - if not mark.stale(after_s=self.stale_after_s, now_ms=stamp) - } - - async def from_book( - self, - account_key: str, - market_id: str, - *, - bid: Decimal | None = None, - ask: Decimal | None = None, - last: Decimal | None = None, - persist: bool = False, - ) -> FairValue | None: - """Mark from a book: the midpoint where both sides exist, otherwise the - last trade, otherwise the one side there is. `None` when there is - nothing to mark with.""" - if bid is not None and ask is not None and ask >= bid: - return await self.set(account_key, market_id, (bid + ask) / 2, source="mid", persist=persist) - if last is not None: - return await self.set(account_key, market_id, last, source="last", persist=persist) - one_side = bid if bid is not None else ask - if one_side is not None: - return await self.set(account_key, market_id, one_side, source="touch", persist=persist) - return None - - async def from_books(self, account_key: str, books: Iterable[tuple[str, Any]], *, persist: bool = False) -> int: - """Mark a batch of `(market_id, LocalBook)` from the streaming layer.""" - marked = 0 - for market_id, book in books: - bid, ask = getattr(book, "best_bid", None), getattr(book, "best_ask", None) - if await self.from_book(account_key, market_id, bid=bid, ask=ask, persist=persist): - marked += 1 - return marked diff --git a/synpath/engine/feeds.py b/synpath/engine/feeds.py index f96148b..fd5b501 100644 --- a/synpath/engine/feeds.py +++ b/synpath/engine/feeds.py @@ -7,10 +7,15 @@ Two kinds of stream per venue, and where each one goes: * **Market data** (books and public prints). Every market a live engine-held - order watches is subscribed on its venue's market stream. The stream keeps a - `LocalBook` up to date in place; the engine is handed that same object once + order watches, or this account holds a position in (the book marks it, + `Engine.exit_price`), is subscribed on its venue's market stream. The stream keeps a + `LocalBook` up to date in place, and its public prints are subscribed + beside it (a last-trade stop and a position's mark read them); the engine is handed that same object once (`Engine.set_book`) and told about every change (`Engine.on_book`), so a - parent always reads the book as the stream last left it. A book the stream + parent always reads the book as the stream last left it. Subscribing also + counts as a change: a market the shared connection already carried gets no + fresh snapshot from the venue, so its parents read the book they are handed + straight away rather than at the next tick. A book the stream has not snapshotted yet, or lost confidence in after a gap, is not `ready`, and parents read it as no book at all. * **This account's orders and fills.** Order updates go to `Engine.on_order`, @@ -24,6 +29,11 @@ private channels, a gap it cannot replay) makes the engine poll that venue's REST state straight away, rather than waiting for the next poll. +`synpath serve` shares these market streams with its `/ws/market` socket +(`synpath.server.market_stream`) through `Feeds.listeners`, so the screen and +the stops read one connection per venue. A book is handed to the engine only +for markets the engine itself watches. + Venues without a stream here are still traded: orders reach the engine through its poll loop, only more slowly. That covers the paper venue, which delivers its own events, and the Polymarket US exchange API, whose streams are @@ -34,7 +44,7 @@ import asyncio import logging from dataclasses import dataclass -from typing import Any, Coroutine, Mapping +from typing import Any, Callable, Coroutine, Mapping from .. import ids from ..ws.base import BookEvent, FillEvent, OrderEvent, StreamStatusEvent, TradeEvent @@ -61,7 +71,13 @@ def default_streams( `market_data` (what `load_market_data` returns) adds a market stream for each venue configured with only its API key: no adapter trades there, but a parent on another venue can still watch its books.""" - out = _trading_streams(adapters, credentials) + out: dict[str, VenueStreams] = {} + for venue, adapter in adapters.items(): + streams = venue_streams(venue, credentials.get(venue), adapter) + if streams is None: + log.info("synpath.engine.feeds: no stream for %s; its orders reach the engine by polling", venue) + continue + out[venue] = streams for venue, key in (market_data or {}).items(): if venue not in out and venue not in adapters: stream = market_stream(venue, key) @@ -70,105 +86,110 @@ def default_streams( return out -def market_stream(venue: str, key: Any) -> Any | None: - """A venue's market stream from its market-data API key alone (a - `MarketDataKey`), or `None` for a venue that has none that way.""" - if venue == "opinion": - from ..opinion import Opinion - from ..ws.opinion import OpinionMarketStream - # The key also lifts the REST limit the stream seeds its books through. - return OpinionMarketStream(api_key=key.api_key, catalog=Opinion(api_key=key.api_key)) - if venue == "predict_fun" and not key.testnet: +def market_stream(venue: str, creds: Any) -> Any | None: + """One venue's market-data stream alone, for a reader with no adapter + (the server's market socket, a venue configured with only its API key). + `creds` is the venue's trading credentials or its `MarketDataKey`; + `None` where the venue streams nothing without credentials it does not + have.""" + streams = venue_streams(venue, creds, None, private=False) + return streams.market if streams is not None else None + + +def venue_streams(venue: str, creds: Any, adapter: Any | None, *, private: bool = True) -> VenueStreams | None: + """One venue's streams from its credentials (and adapter, which some + private streams authenticate through). `private=False` builds the market + stream only, and so does a `MarketDataKey` (an API key with no account + behind it): Opinion, predict.fun and SX Bet stream books on the key + alone. `None` where the venue has no stream these credentials open.""" + from ..trading.credentials import MARKET_DATA_KEYS, MarketDataKey + if isinstance(creds, MarketDataKey): + if venue not in MARKET_DATA_KEYS: + # Public venues stream without any key; a key is no credential here. + return None + private = False + if venue == "kalshi" and creds is not None: + from ..ws.kalshi import KalshiStream + stream = KalshiStream(creds) + return VenueStreams(market=stream, private=stream if private else None) + if venue == "polymarket": + from ..ws.polymarket import PolymarketMarketStream, PolymarketUserStream + return VenueStreams( + market=PolymarketMarketStream(), + private=PolymarketUserStream(trading=adapter) if private and creds is not None and adapter is not None else None, + ) + if venue == "polymarket_us" and creds is not None: + from ..ws.polymarket_us import PolymarketUSMarketStream, PolymarketUSPrivateStream + return VenueStreams(market=PolymarketUSMarketStream(creds), + private=PolymarketUSPrivateStream(creds) if private else None) + if venue == "opinion" and creds is not None: + from ..ws.opinion import OpinionMarketStream, OpinionUserStream + catalog = getattr(adapter, "catalog", None) + if catalog is None: + # The key also lifts the REST limit the stream seeds its books through. + from ..opinion import Opinion + catalog = Opinion(api_key=creds.api_key) + return VenueStreams( + market=OpinionMarketStream(api_key=creds.api_key, catalog=catalog), + private=OpinionUserStream(api_key=creds.api_key, catalog=catalog) if private else None, + ) + if venue == "hyperliquid": + from ..ws.hyperliquid import HyperliquidMarketStream, HyperliquidUserStream + address = getattr(creds, "address", None) + testnet = getattr(creds, "testnet", False) + return VenueStreams( + market=HyperliquidMarketStream(testnet=testnet), + private=HyperliquidUserStream(address=address, testnet=testnet) if private and address else None, + ) + if venue == "predict_fun" and creds is not None and not getattr(creds, "testnet", False): # Mainnet only: the venue documents no test-network WebSocket. - from ..ws.predict_fun import PredictFunMarketStream - return PredictFunMarketStream(api_key=key.api_key) - if venue == "sx_bet": - from ..sx_bet import SxBet - from ..ws.sx_bet import SxBetMarketStream - return SxBetMarketStream(api_key=key.api_key, testnet=key.testnet, - catalog=SxBet(api_key=key.api_key, testnet=key.testnet)) + from ..ws.predict_fun import PredictFunMarketStream, PredictFunUserStream + jwt = getattr(adapter, "jwt", None) + return VenueStreams( + market=PredictFunMarketStream(api_key=creds.api_key), + private=PredictFunUserStream(api_key=creds.api_key, jwt=jwt) if private and jwt else None, + ) + if venue == "limitless": + from ..ws.limitless import LimitlessMarketStream, LimitlessUserStream + token_id, secret = getattr(creds, "token_id", None), getattr(creds, "secret", None) + catalog = getattr(adapter, "catalog", None) + return VenueStreams( + market=LimitlessMarketStream(), + private=LimitlessUserStream(token_id=token_id, secret=secret, catalog=catalog) + if private and token_id and secret else None, + ) + if venue == "sx_bet" and creds is not None: + # Every SX Bet channel, public ones included, needs the API key. + from ..ws.sx_bet import SxBetMarketStream, SxBetUserStream + testnet = bool(getattr(creds, "testnet", False)) + catalog = getattr(adapter, "catalog", None) + if catalog is None: + from ..sx_bet import SxBet + catalog = SxBet(api_key=creds.api_key, testnet=testnet) + address = getattr(creds, "address", None) + if private and not address: + log.warning("synpath.engine.feeds: sx_bet has an API key but no account address, so its orders " + "reach the engine by polling; set SX_BET_ACCOUNT_ADDRESS to the wallet's 0x address " + "for the account stream") + return VenueStreams( + market=SxBetMarketStream(api_key=creds.api_key, testnet=testnet, catalog=catalog), + private=SxBetUserStream(api_key=creds.api_key, testnet=testnet, address=address) + if private and address else None, + ) + if 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 + return VenueStreams(market=BetfairMarketStream(credentials=creds), private=None) + if venue == "smarkets": + # Market data only, and public: a polled stream on REST quotes + # (Smarkets' socket stream is for approved market makers). The + # login, when configured, only undelays the quotes. + from ..ws.smarkets import SmarketsMarketStream + return VenueStreams(market=SmarketsMarketStream(credentials=creds), private=None) return None -def _trading_streams(adapters: Mapping[str, Any], credentials: Mapping[str, Any]) -> dict[str, VenueStreams]: - """Market and account streams for each venue an adapter trades.""" - out: dict[str, VenueStreams] = {} - for venue, adapter in adapters.items(): - creds = credentials.get(venue) - if venue == "kalshi" and creds is not None: - from ..ws.kalshi import KalshiStream - stream = KalshiStream(creds) - out[venue] = VenueStreams(market=stream, private=stream) - elif venue == "polymarket": - from ..ws.polymarket import PolymarketMarketStream, PolymarketUserStream - out[venue] = VenueStreams( - market=PolymarketMarketStream(), - private=PolymarketUserStream(trading=adapter) if creds is not None else None, - ) - elif venue == "polymarket_us" and creds is not None: - from ..ws.polymarket_us import PolymarketUSMarketStream, PolymarketUSPrivateStream - out[venue] = VenueStreams(market=PolymarketUSMarketStream(creds), private=PolymarketUSPrivateStream(creds)) - elif venue == "opinion" and creds is not None: - from ..ws.opinion import OpinionMarketStream, OpinionUserStream - catalog = getattr(adapter, "catalog", None) - out[venue] = VenueStreams( - market=OpinionMarketStream(api_key=creds.api_key, catalog=catalog), - private=OpinionUserStream(api_key=creds.api_key, catalog=catalog), - ) - elif venue == "hyperliquid": - from ..ws.hyperliquid import HyperliquidMarketStream, HyperliquidUserStream - address = getattr(creds, "address", None) - testnet = getattr(creds, "testnet", False) - out[venue] = VenueStreams( - market=HyperliquidMarketStream(testnet=testnet), - private=HyperliquidUserStream(address=address, testnet=testnet) if address else None, - ) - elif venue == "predict_fun" and creds is not None and not getattr(creds, "testnet", False): - # Mainnet only: the venue documents no test-network WebSocket. - from ..ws.predict_fun import PredictFunMarketStream, PredictFunUserStream - jwt = getattr(adapter, "jwt", None) - out[venue] = VenueStreams( - market=PredictFunMarketStream(api_key=creds.api_key), - private=PredictFunUserStream(api_key=creds.api_key, jwt=jwt) if jwt else None, - ) - elif venue == "limitless": - from ..ws.limitless import LimitlessMarketStream, LimitlessUserStream - token_id, secret = getattr(creds, "token_id", None), getattr(creds, "secret", None) - catalog = getattr(adapter, "catalog", None) - out[venue] = VenueStreams( - market=LimitlessMarketStream(), - private=LimitlessUserStream(token_id=token_id, secret=secret, catalog=catalog) if token_id and secret else None, - ) - elif venue == "sx_bet" and creds is not None: - # Every SX Bet channel, public ones included, needs the API key. - from ..ws.sx_bet import SxBetMarketStream, SxBetUserStream - testnet = bool(getattr(creds, "testnet", False)) - catalog = getattr(adapter, "catalog", None) - address = creds.address - if not address: - log.warning("synpath.engine.feeds: sx_bet has an API key but no account address, so its orders " - "reach the engine by polling; set SX_BET_ACCOUNT_ADDRESS to the wallet's 0x address " - "for the account stream") - out[venue] = VenueStreams( - market=SxBetMarketStream(api_key=creds.api_key, testnet=testnet, catalog=catalog), - private=SxBetUserStream(api_key=creds.api_key, testnet=testnet, address=address) if address else None, - ) - elif venue == "betfair" and creds is not None: - # Market data only: no Betfair order entry, so no account stream. - # Every Betfair stream, books included, logs in with the account. - from ..ws.betfair import BetfairMarketStream - out[venue] = VenueStreams(market=BetfairMarketStream(credentials=creds), private=None) - elif venue == "smarkets": - # Market data only, and public: a polled stream on REST quotes - # (Smarkets' socket stream is for approved market makers). The - # login, when configured, only undelays the quotes. - from ..ws.smarkets import SmarketsMarketStream - out[venue] = VenueStreams(market=SmarketsMarketStream(credentials=creds), private=None) - else: - log.info("synpath.engine.feeds: no stream for %s; its orders reach the engine by polling", venue) - return out - - def _has(stream: Any, capability: str) -> bool: return bool((getattr(stream, "has", None) or {}).get(capability)) and callable(getattr(stream, capability, None)) @@ -181,6 +202,10 @@ def __init__(self, engine: Any, streams: Mapping[str, VenueStreams], *, sync_int self.watching: dict[str, set[str]] = {venue: set() for venue in self.streams} self.dispatched = 0 self.closing = False + # Called with `(venue, stream, event)` for every event, after the + # engine has it: how the server's market socket shares these + # connections instead of opening its own. A listener must not block. + self.listeners: list[Callable[[str, Any, Any], None]] = [] def distinct(self) -> list[tuple[str, Any]]: """Every stream object once, with the venue it belongs to.""" @@ -213,11 +238,13 @@ async def start(self) -> None: await self.sync() def wanted(self) -> dict[str, set[str]]: - """Markets live parents watch, by venue, on venues with a market stream.""" + """Markets live parents watch, and markets with an open position (so + the engine can mark it from the book, `Engine.exit_price`), by venue, + on venues with a market stream.""" out: dict[str, set[str]] = {} - for market_id in list(self.engine.orders.by_market): - if not self.engine.orders.watching(market_id): - continue + watched = [market_id for market_id in list(self.engine.managed_order_controller.by_market) if self.engine.managed_order_controller.watching(market_id)] + held = [state.market_id for state in self.engine.ledger.open_positions()] + for market_id in watched + held: venue, _ = ids.split(market_id) if venue is None or venue not in self.streams or self.streams[venue].market is None: continue @@ -237,12 +264,32 @@ async def sync(self) -> int: except Exception as exc: log.warning("synpath.engine.feeds: subscribing %s on %s failed: %s", new, venue, exc) continue + await self.watch_trades(venue, stream, new) self.watching[venue].update(new) for market_id in new: self.register(stream, market_id) + # The venue sends no fresh snapshot for a market the + # connection already carried (the screen's, say): the + # parents read the book they were just handed, now. + await self.engine.managed_order_controller.on_book(market_id) added += len(new) return added + async def watch_trades(self, venue: str, stream: Any, market_ids: list[str]) -> None: + """The public prints too, where the venue streams them on their own + channel (Kalshi, Polymarket US; Polymarket's come with its book): a + stop that triggers on the last trade hears every one, and a position + whose book has no bid or ask is marked at it. A failure is logged; + the book stays subscribed.""" + if not _has(stream, "watch_trades"): + return + if getattr(type(stream), "watch_trades", None) is getattr(type(stream), "watch_order_book", None): + return + try: + await stream.watch_trades(market_ids) + except Exception as exc: + log.warning("synpath.engine.feeds: subscribing trades for %s on %s failed: %s", market_ids, venue, exc) + def register(self, stream: Any, market_id: str) -> None: """Hand the engine the stream's own book object for this market.""" book = stream.book(market_id) @@ -254,7 +301,12 @@ def register(self, stream: Any, market_id: str) -> None: async def dispatch(self, venue: str, stream: Any, event: Any) -> None: self.dispatched += 1 if isinstance(event, BookEvent): - self.register(stream, event.market_id) + # Only the books the engine watches are handed to it. A market + # someone else subscribed on this shared stream may be unwatched + # later, and its book would then sit in the engine still marked + # ready while nothing updates it. + if event.market_id in self.watching.get(venue, ()): + self.register(stream, event.market_id) await self.engine.on_book(event) elif isinstance(event, TradeEvent): await self.engine.on_trade(event) @@ -282,6 +334,7 @@ async def pump(self, venue: str, stream: Any) -> None: await self.dispatch(venue, stream, event) except Exception: log.exception("synpath.engine.feeds: handling a %s event from %s failed", type(event).__name__, venue) + self.tell_listeners(venue, stream, event) if self.closing: return log.warning("synpath.engine.feeds: the %s %s stream ended; that venue is polled until a restart", @@ -289,6 +342,13 @@ async def pump(self, venue: str, stream: Any) -> None: while self.engine.running and not self.closing: await asyncio.sleep(1.0) + def tell_listeners(self, venue: str, stream: Any, event: Any) -> None: + for listener in self.listeners: + try: + listener(venue, stream, event) + except Exception: + log.exception("synpath.engine.feeds: a listener failed on a %s event from %s", type(event).__name__, venue) + async def _sync_loop(self) -> None: while self.engine.running: await asyncio.sleep(self.sync_interval_s) diff --git a/synpath/engine/journal.py b/synpath/engine/journal.py index 362cbe9..b4564b9 100644 --- a/synpath/engine/journal.py +++ b/synpath/engine/journal.py @@ -32,15 +32,35 @@ import socket import time import uuid -from dataclasses import dataclass, field from decimal import Decimal from typing import Any, AsyncIterator, Iterable +from ..errors import BadRequest from ..trading.types import Account, Fill, Order, OrderRequest +from .storage_types import Intent, IntentState, JournalEvent, Lease, LeaseLost +from .submissions import request_identity +from .order_storage import OrderRecords, UNATTRIBUTED_USER, schema as order_schema -SCHEMA_VERSION = 4 +from .execution_scope import MANAGED_NAMESPACE, storage_identity + +SCHEMA_VERSION = 11 SCHEMA = """ +CREATE TABLE IF NOT EXISTS submissions ( + client_order_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + account_key TEXT NOT NULL, + venue TEXT NOT NULL, + request TEXT NOT NULL, + effective_request TEXT NOT NULL, + result_id TEXT, + state TEXT NOT NULL DEFAULT 'pending', + error TEXT, + created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS submissions_owner ON submissions(user_id, account_key, created_ts); + CREATE TABLE IF NOT EXISTS meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL @@ -151,14 +171,6 @@ payload TEXT NOT NULL ); -CREATE TABLE IF NOT EXISTS fair_values ( - account_key TEXT NOT NULL, - market_id TEXT NOT NULL, - value TEXT NOT NULL, - source TEXT, - ts INTEGER NOT NULL, - PRIMARY KEY (account_key, market_id) -); CREATE TABLE IF NOT EXISTS buckets ( id TEXT PRIMARY KEY, @@ -185,6 +197,20 @@ """ +# Stop tables own their recovery snapshots. The legacy managed table remains +# the source only for advanced types that have not migrated in this checkpoint. +MANAGED_SOURCE = """ + SELECT id, kind, state, market_id, created_ts, payload FROM managed_orders + WHERE kind NOT IN ('stop_market', 'stop_limit') + UNION ALL + SELECT id, 'stop_market', state, market_id, created_ts, snapshot FROM stop_market_orders + WHERE json_extract(snapshot, '$.kind') = 'stop_market' + UNION ALL + SELECT id, 'stop_limit', state, market_id, created_ts, snapshot FROM stop_limit_orders + WHERE json_extract(snapshot, '$.kind') = 'stop_limit' +""" + + def now_ms() -> int: return int(time.time() * 1000) @@ -217,79 +243,26 @@ def _word(value: Any) -> str: return str(getattr(value, "value", value)) -class LeaseLost(RuntimeError): - """Another engine holds the journal, or this one's lease expired.""" - class _Held(Exception): """Internal: the lease row belongs to someone else. Becomes `LeaseLost`.""" -class IntentState: - PLANNED = "planned" - """Written, risk passed, not yet sent.""" - SENDING = "sending" - """Handed to the venue; the answer has not come back.""" - SENT = "sent" - SETTLED = "settled" - """The order reached a terminal status.""" - REJECTED = "rejected" - FAILED = "failed" - """The venue refused it, or the call failed in a way that proves nothing left.""" - LOST = "lost" - """In doubt for longer than the sweep allows and not found at the venue.""" - IN_DOUBT = (IntentState.SENDING,) -@dataclass(frozen=True, slots=True) -class JournalEvent: - seq: int - ts: int - kind: str - key: str | None - payload: dict[str, Any] - - -@dataclass(slots=True) -class Intent: - client_order_id: str - state: str - operation: str - venue: str - account_key: str - market_id: str | None - order_id: str | None - target_order_id: str | None - book: str | None - trader: str | None - attempts: int - request: dict[str, Any] - detail: str | None - created_ts: int - updated_ts: int - @property - def age_ms(self) -> int: - return now_ms() - self.updated_ts -@dataclass(slots=True) -class Lease: - name: str - owner: str - holder: str - acquired_ts: int - expires_ts: int - ttl_ms: int = 30_000 - renew_task: Any = field(default=None, repr=False) - class Journal: """The engine's write-ahead log and its materialized state.""" - def __init__(self, path: str | os.PathLike[str] = "synpath.db", *, owner: str | None = None, busy_timeout_ms: int = 10_000): + def __init__(self, path: str | os.PathLike[str] = "synpath.db", *, owner: str | None = None, busy_timeout_ms: int = 10_000, user_id: str = "local-user"): + if not user_id.strip(): + raise ValueError("user_id must not be empty") + self.user_id = user_id self.path = str(path) self.owner = owner or f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}" self.busy_timeout_ms = busy_timeout_ms @@ -300,33 +273,156 @@ def __init__(self, path: str | os.PathLike[str] = "synpath.db", *, owner: str | # -- lifecycle ------------------------------------------------------------ async def open(self) -> "Journal": + if self.is_open: + return self + from .database import connect_sqlite + self._db = await connect_sqlite(self.path) try: - import aiosqlite - except ImportError as exc: # pragma: no cover - depends on the environment - raise ImportError("the engine's journal needs aiosqlite: pip install synpath") from exc - connection = aiosqlite.connect(self.path, isolation_level=None) - # aiosqlite runs each connection on its own thread. Marking it a - # daemon means a journal somebody forgot to close cannot keep the - # process alive after everything else has finished. - connection.daemon = True - self._db = await connection - self._db.row_factory = aiosqlite.Row - await self._db.execute(f"PRAGMA busy_timeout={self.busy_timeout_ms}") - await self._db.execute("PRAGMA journal_mode=WAL") - # An order must be on disk before it is on the wire, so the write that - # records it cannot be one the operating system is still holding. - await self._db.execute("PRAGMA synchronous=FULL") - await self._db.execute("PRAGMA foreign_keys=ON") - await self._db.executescript(SCHEMA) - await self._upgrade() - await self._db.execute("CREATE INDEX IF NOT EXISTS orders_parent ON orders(parent_id)") - await self._db.execute( - "INSERT INTO meta(key, value) VALUES('schema_version', ?) " - "ON CONFLICT(key) DO UPDATE SET value=excluded.value", - (str(SCHEMA_VERSION),), - ) + await self._db.execute(f"PRAGMA busy_timeout={self.busy_timeout_ms}") + await self._db.execute("PRAGMA journal_mode=WAL") + # An order must be on disk before it is on the wire, so the write that + # records it cannot be one the operating system is still holding. + await self._db.execute("PRAGMA synchronous=FULL") + await self._db.execute("PRAGMA foreign_keys=ON") + await self._validate_existing_store() + await self._db.executescript(SCHEMA) + await self._upgrade() + await self._db.executescript(order_schema()) + self._order_records = OrderRecords(self._db, user_id=self.user_id if self._has_owner else None) + await self._upgrade_order_records() + await self._upgrade_submissions() + await self._db.execute("CREATE INDEX IF NOT EXISTS orders_parent ON orders(parent_id)") + await self._db.execute( + "INSERT INTO meta(key, value) VALUES('schema_version', ?) " + "ON CONFLICT(key) DO NOTHING", + ("6",), + ) + await self._db.execute("BEGIN IMMEDIATE") + try: + from .migration import upgrade + await upgrade(self._db, self.user_id) + await self._db.execute("COMMIT") + self._order_records.user_id = self.user_id + except BaseException: + await self._db.execute("ROLLBACK") + raise + except BaseException: + await self._db.close() + self._db = None + raise return self + async def _validate_existing_store(self): + async with self._db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('meta','users')") as cursor: + names = {row["name"] for row in await cursor.fetchall()} + self._has_owner = "users" in names + if "meta" in names: + async with self._db.execute("SELECT value FROM meta WHERE key='schema_version'") as cursor: + version = await cursor.fetchone() + if version is not None and int(version["value"]) > SCHEMA_VERSION: + raise ValueError("unsupported SQLite journal schema version") + if self._has_owner: + async with self._db.execute("SELECT user_id FROM users") as cursor: + rows = await cursor.fetchall() + if len(rows) != 1 or rows[0]["user_id"] != self.user_id: + raise ValueError("configured user_id differs from the stored execution owner") + + async def _upgrade_submissions(self) -> None: + """Keep pre-checkpoint client keys reserved without inventing requests.""" + async with self._db.execute("SELECT value FROM meta WHERE key='submissions_version'") as cursor: + if await cursor.fetchone(): + return + from .order_storage import ORDER_TABLES + await self._db.execute("BEGIN IMMEDIATE") + try: + for kind, table in ORDER_TABLES.items(): + async with self._db.execute( + f"SELECT * FROM {table} WHERE request IS NOT NULL AND client_order_id IS NOT NULL" + ) as cursor: + rows = await cursor.fetchall() + for row in rows: + request = OrderRequest.stored(json.loads(row["request"])) + snapshot = json.loads(row["snapshot"]) + if snapshot.get("account"): + account = Account.model_validate(snapshot["account"]) + else: + account = request.account or Account(venue=row["venue"]) + request = request.model_copy(update={"account": account, "user_id": row["user_id"], + "client_order_id": row["client_order_id"]}) + result_id = (row["id"] if kind.startswith("stop") and snapshot.get("kind") else row["venue_order_id"]) + await self._db.execute( + "INSERT INTO submissions(client_order_id,user_id,account_key,venue,request,effective_request,result_id,state,created_ts,updated_ts) " + "VALUES(?,?,?,?,?,?,?,?,?,?) ON CONFLICT(client_order_id) DO NOTHING", + (row["client_order_id"], row["user_id"], account.key, row["venue"], + _dump(request_identity(request)), _dump(request.model_dump(mode="json")), result_id, + "accepted" if result_id else "pending", row["created_ts"], row["updated_ts"]), + ) + await self._db.execute("INSERT INTO meta(key,value) VALUES('submissions_version','1')") + await self._db.execute("COMMIT") + except BaseException: + await self._db.execute("ROLLBACK") + raise + + @property + def is_open(self) -> bool: + return self._db is not None + + async def typed_order(self, kind: str, venue: str, application_id: str) -> dict[str, Any] | None: + """A type-owned instruction and its latest durable state.""" + return await self._order_records.get(kind, venue, application_id) + + async def _upgrade_order_records(self) -> None: + """Backfill once without inventing ownership or missing instructions. + + Old venue records contain execution results, not necessarily the + original user request. Keep that distinction and attribute unknown + historical owners to an explicit legacy identity. + """ + async with self._db.execute("SELECT value FROM meta WHERE key='order_records_version'") as cursor: + if await cursor.fetchone(): + return + await self._db.execute("BEGIN IMMEDIATE") + try: + async with self._db.execute("SELECT * FROM intents WHERE operation='create'") as cursor: + intents = await cursor.fetchall() + for row in intents: + try: + request = OrderRequest.stored(json.loads(row["request"])) + except (ValueError, TypeError): + continue + await self._order_records.write_request( + request, venue=row["venue"], account_key=row["account_key"], + client_order_id=row["client_order_id"], user_id=request.user_id or UNATTRIBUTED_USER, + stamp=row["created_ts"], state=row["state"], + ) + async with self._db.execute("SELECT payload FROM orders") as cursor: + rows = await cursor.fetchall() + for row in rows: + try: + order = Order.model_validate(json.loads(row["payload"])) + except (ValueError, TypeError): + continue + if order.held_by.value == "venue": + await self._order_records.write_order(order, stamp=now_ms()) + async with self._db.execute("SELECT payload FROM managed_orders") as cursor: + rows = await cursor.fetchall() + for row in rows: + snapshot = json.loads(row["payload"]) + if snapshot.get("venue") and snapshot.get("request"): + snapshot = {**snapshot, "request": {**snapshot["request"], + "user_id": snapshot["request"].get("user_id") or UNATTRIBUTED_USER}} + await self._order_records.write_managed(snapshot, user_id=UNATTRIBUTED_USER, stamp=now_ms()) + await self._db.execute("INSERT INTO meta(key,value) VALUES('order_records_version','1')") + await self._db.execute("COMMIT") + except BaseException: + await self._db.execute("ROLLBACK") + raise + + async def lease_records(self) -> list[dict[str, Any]]: + """Lease inspection without exposing a database connection to callers.""" + async with self._db.execute("SELECT name, owner, expires_ts FROM leases") as cursor: + return [dict(row) async for row in cursor] + async def schema_version(self) -> int: async with self._db.execute("SELECT value FROM meta WHERE key='schema_version'") as cursor: row = await cursor.fetchone() @@ -451,49 +547,144 @@ async def replay(self, since: int = 0, *, kinds: Iterable[str] | None = None, li async for row in cursor: yield JournalEvent(row["seq"], row["ts"], row["kind"], row["key"], json.loads(row["payload"])) + async def events_page(self, *, kinds: Iterable[str] = (), prefixes: Iterable[str] = (), + keys: Iterable[str] | None = None, before: int | None = None, + limit: int = 500) -> list[JournalEvent]: + """Events newest first: of these `kinds` or kinds starting with these + `prefixes` (either, or all when both are empty), optionally only these + `keys`, older than sequence `before`. The order log reads it.""" + sql = "SELECT seq, ts, kind, key, payload FROM events WHERE 1=1" + args: list[Any] = [] + kind_terms = [("kind = ?", kind) for kind in kinds] + [("kind LIKE ?", f"{prefix}%") for prefix in prefixes] + if kind_terms: + sql += " AND (" + " OR ".join(term for term, _ in kind_terms) + ")" + args += [value for _, value in kind_terms] + if keys is not None: + wanted = list(dict.fromkeys(keys)) + if not wanted: + return [] + sql += f" AND key IN ({','.join('?' * len(wanted))})" + args += wanted + if before is not None: + sql += " AND seq < ?" + args.append(int(before)) + sql += " ORDER BY seq DESC LIMIT ?" + args.append(int(limit)) + async with self._db.execute(sql, args) as cursor: + return [JournalEvent(row["seq"], row["ts"], row["kind"], row["key"], json.loads(row["payload"])) + async for row in cursor] + async def last_seq(self) -> int: async with self._db.execute("SELECT COALESCE(MAX(seq), 0) AS seq FROM events") as cursor: row = await cursor.fetchone() return int(row["seq"]) - # -- intents -------------------------------------------------------------- + # -- submission identity -------------------------------------------------- - async def record_intent( - self, - request: OrderRequest, - *, - client_order_id: str, - venue: str, - account: Account, - operation: str = "create", - target_order_id: str | None = None, - ) -> Intent: - """Write what the engine is about to do, before it does it.""" + async def submission(self, client_order_id: str) -> dict[str, Any] | None: + async with self._db.execute("SELECT * FROM submissions WHERE client_order_id=?", (client_order_id,)) as cursor: + row = await cursor.fetchone() + if row is None: + return None + result = dict(row) + for field in ("request", "effective_request", "error"): + result[field] = json.loads(result[field]) if result[field] else None + return result + + async def record_submission(self, original: OrderRequest, effective: OrderRequest, + *, venue: str, account: Account | None) -> None: + """Reserve the instruction and its audit event in one transaction.""" stamp = now_ms() - body = request.model_dump(mode="json") + identity = request_identity(original) + account_key = account.key if account else MANAGED_NAMESPACE async with self._write: await self._db.execute("BEGIN IMMEDIATE") try: - await self._db.execute( - """INSERT INTO intents(client_order_id, state, operation, venue, account_key, market_id, - order_id, target_order_id, book, trader, attempts, request, detail, - created_ts, updated_ts) - VALUES(?,?,?,?,?,?,NULL,?,?,?,0,?,NULL,?,?) - ON CONFLICT(client_order_id) DO NOTHING""", - (client_order_id, IntentState.PLANNED, operation, venue, account.key, request.market_id, - target_order_id, request.book, request.trader, _dump(body), stamp, stamp), + cursor = await self._db.execute( + "INSERT INTO submissions(client_order_id,user_id,account_key,venue,request,effective_request,created_ts,updated_ts) " + "VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(client_order_id) DO NOTHING", + (original.client_order_id, original.user_id, account_key, venue, + _dump(identity), _dump(effective.model_dump(mode="json")), stamp, stamp), ) - await self._db.execute( - "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)", - (stamp, "intent.planned", client_order_id, - _dump({"operation": operation, "venue": venue, "account": account.key, "request": body, - "target_order_id": target_order_id})), + if cursor.rowcount: + await self._db.execute( + "INSERT INTO events(ts,kind,key,payload) VALUES(?,?,?,?)", + (stamp, "submission.reserved", original.client_order_id, + _dump({"user_id": original.user_id, "account": account_key, "venue": venue, + "request": identity, "effective_request": effective.model_dump(mode="json")})), + ) + else: + async with self._db.execute("SELECT request FROM submissions WHERE client_order_id=?", + (original.client_order_id,)) as existing_cursor: + existing = await existing_cursor.fetchone() + if json.loads(existing["request"]) != identity: + raise BadRequest("client_order_id already identifies a different durable instruction") + await self._db.execute("COMMIT") + except BaseException: + await self._db.execute("ROLLBACK") + raise + + async def reject_submission(self, client_order_id: str, error: dict[str, Any]) -> None: + async with self._write: + await self._db.execute("BEGIN IMMEDIATE") + try: + stamp = now_ms() + result = await self._db.execute( + "UPDATE submissions SET state='rejected', error=?, updated_ts=? WHERE client_order_id=? AND result_id IS NULL", + (_dump(error), stamp, client_order_id), ) + if result.rowcount: + await self._db.execute( + "INSERT INTO events(ts,kind,key,payload) VALUES(?,?,?,?)", + (stamp, "submission.rejected", client_order_id, _dump(error)), + ) await self._db.execute("COMMIT") except BaseException: await self._db.execute("ROLLBACK") raise - return await self.intent(client_order_id) # type: ignore[return-value] + + # -- intents -------------------------------------------------------------- + + async def record_intent(self, request: OrderRequest, *, client_order_id: str, + venue: str, account: Account, operation: str = "create", + target_order_id: str | None = None) -> Intent: + """Commit an instruction and its pending side effect before sending.""" + async with self._write: + await self._db.execute("BEGIN IMMEDIATE") + try: + await self._write_intent(request, client_order_id=client_order_id, venue=venue, + account=account, operation=operation, + target_order_id=target_order_id, stamp=now_ms()) + await self._db.execute("COMMIT") + except BaseException: + await self._db.execute("ROLLBACK") + raise + return await self.intent(client_order_id) + + async def _write_intent(self, request: OrderRequest, *, client_order_id: str, + venue: str, account: Account, operation: str = "create", + target_order_id: str | None = None, stamp: int) -> None: + body = request.model_dump(mode="json") + await self._db.execute( + """INSERT INTO intents(client_order_id, state, operation, venue, account_key, market_id, + order_id, target_order_id, book, trader, attempts, request, detail, + created_ts, updated_ts) + VALUES(?,?,?,?,?,?,NULL,?,?,?,0,?,NULL,?,?) + ON CONFLICT(client_order_id) DO NOTHING""", + (client_order_id, IntentState.PLANNED, operation, venue, account.key, request.market_id, + target_order_id, request.book, request.trader, _dump(body), stamp, stamp), + ) + if operation == "create": + await self._order_records.write_request( + request, venue=venue, account_key=account.key, client_order_id=client_order_id, + user_id=request.user_id or self.user_id, stamp=stamp, + ) + await self._db.execute( + "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)", + (stamp, "intent.planned", client_order_id, + _dump({"operation": operation, "venue": venue, "account": account.key, "request": body, + "target_order_id": target_order_id})), + ) async def mark_intent( self, @@ -563,39 +754,49 @@ def _intent_of(row: Any) -> Intent: # -- orders and fills ----------------------------------------------------- async def upsert_order(self, order: Order, *, event: str = "order.updated") -> None: - stamp = now_ms() - body = order.model_dump(mode="json") - account_key = order.account.key if order.account else f"{order.venue}:default" async with self._write: await self._db.execute("BEGIN IMMEDIATE") try: - await self._db.execute( - """INSERT INTO orders(venue, id, client_order_id, account_key, market_id, side, - status, terminal, book, trader, quantity, filled, price, parent_id, payload, - created_ts, updated_ts) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(venue, id) DO UPDATE SET - client_order_id=COALESCE(excluded.client_order_id, orders.client_order_id), - status=excluded.status, terminal=excluded.terminal, filled=excluded.filled, - price=excluded.price, quantity=excluded.quantity, payload=excluded.payload, - book=COALESCE(excluded.book, orders.book), trader=COALESCE(excluded.trader, orders.trader), - parent_id=COALESCE(excluded.parent_id, orders.parent_id), - updated_ts=excluded.updated_ts""", - (order.venue, order.id, order.client_order_id, account_key, order.market_id, - _word(order.side), _word(order.status), 1 if order.is_terminal else 0, order.book, order.trader, - str(order.quantity), str(order.filled), _text(order.price), - order.parent_id or order.tags.get("parent"), _dump(body), - order.created_at or stamp, stamp), - ) - await self._db.execute( - "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)", - (stamp, event, f"{order.venue}:{order.id}", _dump(body)), - ) + await self._write_order(order, event=event, stamp=now_ms()) await self._db.execute("COMMIT") except BaseException: await self._db.execute("ROLLBACK") raise + async def _write_order(self, order: Order, *, event: str, stamp: int) -> None: + """Write the order projection inside the caller's transaction.""" + body = order.model_dump(mode="json") + storage_venue, account_key = storage_identity(order) + await self._db.execute( + """INSERT INTO orders(venue, id, client_order_id, account_key, market_id, side, + status, terminal, book, trader, quantity, filled, price, parent_id, payload, + created_ts, updated_ts) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(venue, id) DO UPDATE SET + client_order_id=COALESCE(excluded.client_order_id, orders.client_order_id), + status=excluded.status, terminal=excluded.terminal, filled=excluded.filled, + price=excluded.price, quantity=excluded.quantity, payload=excluded.payload, + book=COALESCE(excluded.book, orders.book), trader=COALESCE(excluded.trader, orders.trader), + parent_id=COALESCE(excluded.parent_id, orders.parent_id), + updated_ts=excluded.updated_ts""", + (storage_venue, order.id, order.client_order_id, account_key, order.market_id, + _word(order.side), _word(order.status), 1 if order.is_terminal else 0, order.book, order.trader, + str(order.quantity), str(order.filled), _text(order.price), + order.parent_id or order.tags.get("parent"), _dump(body), + order.created_at or stamp, stamp), + ) + if order.held_by.value == "venue": + await self._order_records.write_order(order, stamp=stamp) + await self._db.execute( + "UPDATE submissions SET result_id=?, state='accepted', updated_ts=? " + "WHERE client_order_id=? AND user_id=? AND account_key=?", + (order.id, stamp, order.client_order_id, order.user_id, account_key), + ) + await self._db.execute( + "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)", + (stamp, event, f"{storage_venue}:{order.id}", _dump(body)), + ) + async def record_fill(self, fill: Fill, *, book: str | None = None, trader: str | None = None) -> bool: """Store a fill. `False` means this fill id was already known, which happens on every venue that delivers at least once.""" @@ -633,7 +834,8 @@ async def record_fill(self, fill: Fill, *, book: str | None = None, trader: str raise return changed - async def order(self, venue: str, order_id: str) -> Order | None: + async def order(self, venue: str | None, order_id: str) -> Order | None: + venue = venue or MANAGED_NAMESPACE async with self._db.execute("SELECT payload FROM orders WHERE venue=? AND id=?", (venue, order_id)) as cursor: row = await cursor.fetchone() return Order.model_validate(json.loads(row["payload"])) if row else None @@ -650,11 +852,50 @@ async def open_orders(self, *, venue: str | None = None, account_key: str | None args: list[Any] = [] for column, value in (("venue", venue), ("account_key", account_key), ("book", book)): if value is not None: - sql += f" AND {column}=?" - args.append(value) + if column in ("venue", "account_key"): + sql += (f" AND ({column}=? OR (orders.venue=? AND EXISTS " + f"(SELECT 1 FROM parent_execution_accounts scope WHERE scope.parent_id=orders.id AND scope.{column}=?)))") + args.extend([value, MANAGED_NAMESPACE, value]) + else: + sql += f" AND {column}=?" + args.append(value) async with self._db.execute(sql + " ORDER BY updated_ts", args) as cursor: return [Order.model_validate(json.loads(row["payload"])) async for row in cursor] + async def orders_page(self, *, venue: str | None = None, book: str | None = None, market_id: str | None = None, + since_ts: int | None = None, before: tuple[int, str, str] | None = None, + open_only: bool = False, limit: int | None = 100) -> list[tuple[Order, tuple[int, str, str]]]: + """Orders in any state, venue orders and engine-held parents alike, + newest first by creation, each with its paging key `(created_ts, venue, + id)`. `before` is the key of the last row of the previous page; the key + is unique, so paging never repeats or skips a row. `open_only` keeps + the non-terminal ones; `limit=None` returns every match. Served by the + `orders_recent` and `orders_market_recent` indexes.""" + sql = "SELECT payload, created_ts, venue, id FROM orders WHERE " + ("terminal=0" if open_only else "1=1") + args: list[Any] = [] + for column, value in (("venue", venue), ("book", book), ("market_id", market_id)): + if value is not None: + if column in ("venue", "account_key"): + sql += (f" AND ({column}=? OR (orders.venue=? AND EXISTS " + f"(SELECT 1 FROM parent_execution_accounts scope WHERE scope.parent_id=orders.id AND scope.{column}=?)))") + args.extend([value, MANAGED_NAMESPACE, value]) + else: + sql += f" AND {column}=?" + args.append(value) + if since_ts is not None: + sql += " AND created_ts >= ?" + args.append(since_ts) + if before is not None: + sql += " AND (created_ts, venue, id) < (?, ?, ?)" + args.extend(before) + sql += " ORDER BY created_ts DESC, venue DESC, id DESC" + if limit is not None: + sql += " LIMIT ?" + args.append(limit) + async with self._db.execute(sql, args) as cursor: + return [(Order.model_validate(json.loads(row["payload"])), (int(row["created_ts"]), row["venue"], row["id"])) + async for row in cursor] + async def fills(self, *, since_ts: int | None = None, venue: str | None = None, book: str | None = None) -> list[Fill]: """Fills as recorded. The strategy that placed the order is carried in `info["book"]`, because a venue's fill does not know about books and @@ -709,15 +950,16 @@ async def save_bucket(self, bucket: dict[str, Any]) -> None: await self._db.execute("DELETE FROM bucket_members WHERE bucket_id=?", (bucket["id"],)) for position, member in enumerate(members): await self._db.execute( - "INSERT INTO bucket_members(bucket_id, market_id, flip, position) VALUES(?,?,?,?)", - (bucket["id"], member["market_id"], 1 if member.get("flip") else 0, position), + "INSERT INTO bucket_members(bucket_id, market_id, flip, enabled, position) VALUES(?,?,?,?,?)", + (bucket["id"], member["market_id"], 1 if member.get("flip") else 0, + 0 if member.get("enabled") is False else 1, position), ) await self._db.execute( "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)", (stamp, "bucket.saved", bucket["id"], _dump(bucket)), ) await self._db.execute("COMMIT") - except Exception: + except BaseException: await self._db.execute("ROLLBACK") raise @@ -727,9 +969,10 @@ async def bucket(self, bucket_id: str) -> dict[str, Any] | None: if row is None: return None async with self._db.execute( - "SELECT market_id, flip FROM bucket_members WHERE bucket_id=? ORDER BY position", (bucket_id,) + "SELECT market_id, flip, enabled FROM bucket_members WHERE bucket_id=? ORDER BY position", (bucket_id,) ) as cursor: - members = [{"market_id": m["market_id"], "flip": bool(m["flip"])} async for m in cursor] + members = [{"market_id": m["market_id"], "flip": bool(m["flip"]), "enabled": bool(m["enabled"])} + async for m in cursor] return {"id": row["id"], "book": row["book"], "name": row["name"], "status": row["status"], "created_ms": row["created_ts"], "updated_ms": row["updated_ts"], "members": members} @@ -762,10 +1005,38 @@ async def archive_bucket(self, bucket_id: str) -> bool: # -- engine-held orders --------------------------------------------------- - async def save_managed(self, snapshot: dict[str, Any]) -> None: - """Write one engine-held order's whole state. Called on every change, - because a parent that is not on disk did not happen.""" - stamp = now_ms() + async def save_managed(self, snapshot: dict[str, Any], *, order: Order | None = None) -> None: + """Commit the authoritative snapshot, shared projection and event.""" + async with self._write: + await self._db.execute("BEGIN IMMEDIATE") + try: + stamp = now_ms() + await self._write_managed(snapshot, stamp=stamp) + if order is not None: + await self._write_order(order, event=f"managed.{snapshot['state']}", stamp=stamp) + await self._db.execute("COMMIT") + except BaseException: + await self._db.execute("ROLLBACK") + raise + + async def plan_managed_child(self, snapshot: dict[str, Any], request: OrderRequest, + *, venue: str, account: Account, parent_order: Order | None = None) -> None: + """The irreversible parent transition and child dispatch commit together.""" + async with self._write: + await self._db.execute("BEGIN IMMEDIATE") + try: + stamp = now_ms() + await self._write_managed(snapshot, stamp=stamp) + if parent_order is not None: + await self._write_order(parent_order, event=f"managed.{snapshot['state']}", stamp=stamp) + await self._write_intent(request, client_order_id=request.client_order_id, + venue=venue, account=account, stamp=stamp) + await self._db.execute("COMMIT") + except BaseException: + await self._db.execute("ROLLBACK") + raise + + async def _write_managed(self, snapshot: dict[str, Any], *, stamp: int) -> None: account = snapshot.get("account") or {} account_key: str | None if not account or not account.get("venue"): @@ -776,31 +1047,44 @@ async def save_managed(self, snapshot: dict[str, Any]) -> None: account_key = str(account["venue"]) request = snapshot.get("request") or {} children = snapshot.get("children") or [] - async with self._write: - await self._db.execute("BEGIN IMMEDIATE") - try: - await self._db.execute( - """INSERT INTO managed_orders(id, kind, state, account_key, market_id, book, trader, - payload, child_order_id, created_ts, updated_ts) - VALUES(?,?,?,?,?,?,?,?,?,?,?) - ON CONFLICT(id) DO UPDATE SET state=excluded.state, payload=excluded.payload, - child_order_id=excluded.child_order_id, updated_ts=excluded.updated_ts""", - (snapshot["id"], snapshot.get("kind", ""), snapshot.get("state", ""), account_key, - request.get("market_id", ""), request.get("book"), request.get("trader"), - _dump(snapshot), children[-1]["order_id"] if children else None, - int(snapshot.get("created_ms") or stamp), stamp), - ) + await self._db.execute( + """INSERT INTO managed_orders(id, kind, state, account_key, market_id, book, trader, + payload, child_order_id, created_ts, updated_ts) + VALUES(?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(id) DO UPDATE SET state=excluded.state, payload=excluded.payload, + child_order_id=excluded.child_order_id, updated_ts=excluded.updated_ts""", + (snapshot["id"], snapshot.get("kind", ""), snapshot.get("state", ""), account_key, + request.get("market_id", ""), request.get("book"), request.get("trader"), + _dump(snapshot), children[-1]["order_id"] if children else None, + int(snapshot.get("created_ms") or stamp), stamp), + ) + if snapshot.get("execution_accounts"): + async with self._db.execute("SELECT venue,account_key FROM parent_execution_accounts WHERE parent_id=?", + (snapshot["id"],)) as cursor: + existing_scope = {row["venue"]: row["account_key"] async for row in cursor} + proposed_scope = {venue: Account.model_validate(body).key + for venue, body in snapshot["execution_accounts"].items()} + if existing_scope and existing_scope != proposed_scope: + raise ValueError("a routed parent's saved execution scope is immutable") + for venue, body in snapshot["execution_accounts"].items(): + execution_account = Account.model_validate(body) await self._db.execute( - "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)", - (stamp, f"managed.{snapshot.get('state', 'updated')}", snapshot["id"], _dump(snapshot)), - ) - await self._db.execute("COMMIT") - except BaseException: - await self._db.execute("ROLLBACK") - raise + "INSERT INTO parent_execution_accounts(parent_id,venue,account_key) VALUES(?,?,?) " + "ON CONFLICT(parent_id,venue) DO NOTHING", + (snapshot["id"], venue, execution_account.key)) + await self._order_records.write_managed(snapshot, user_id=self.user_id, stamp=stamp) + await self._db.execute( + "INSERT INTO events(ts, kind, key, payload) VALUES(?,?,?,?)", + (stamp, f"managed.{snapshot.get('state', 'updated')}", snapshot["id"], _dump(snapshot)), + ) + await self._db.execute( + "UPDATE submissions SET result_id=?, state='accepted', updated_ts=? " + "WHERE client_order_id=? AND user_id=?", + (snapshot["id"], stamp, request.get("client_order_id"), request.get("user_id")), + ) async def managed(self, *, states: Iterable[str] | None = None) -> list[dict[str, Any]]: - sql = "SELECT payload FROM managed_orders" + sql = f"SELECT payload FROM ({MANAGED_SOURCE}) AS managed_source" args: list[Any] = [] names = list(states or []) if names: @@ -814,16 +1098,24 @@ async def managed_on(self, market_id: str) -> list[tuple[str, dict[str, Any]]]: `(kind, snapshot)`, oldest first. A bucket's orders are found by `bucket:`.""" async with self._db.execute( - "SELECT kind, payload FROM managed_orders WHERE market_id=? ORDER BY created_ts", (market_id,) + f"SELECT kind, payload FROM ({MANAGED_SOURCE}) AS managed_source WHERE market_id=? ORDER BY created_ts", (market_id,) ) as cursor: return [(row["kind"], json.loads(row["payload"])) async for row in cursor] + async def routed_account_keys(self, venue: str) -> list[str]: + """Indexed live-parent account scope for polling/reconciliation guards.""" + async with self._db.execute( + "SELECT DISTINCT scope.account_key FROM parent_execution_accounts scope " + "JOIN managed_orders parent ON parent.id=scope.parent_id " + "WHERE scope.venue=? AND parent.state IN ('waiting','working','cancelling')", (venue,)) as cursor: + return [row["account_key"] async for row in cursor] + async def managed_one(self, parent_id: str) -> tuple[str, dict[str, Any]] | None: - async with self._db.execute("SELECT kind, payload FROM managed_orders WHERE id=?", (parent_id,)) as cursor: + async with self._db.execute(f"SELECT kind, payload FROM ({MANAGED_SOURCE}) AS managed_source WHERE id=?", (parent_id,)) as cursor: row = await cursor.fetchone() return (row["kind"], json.loads(row["payload"])) if row else None - # -- cursors, config, fair values ---------------------------------------- + # -- cursors, config ----------------------------------------------------- async def set_cursor(self, name: str, value: Any) -> None: async with self._write: @@ -857,23 +1149,6 @@ async def config(self, kind: str, version: int | None = None) -> tuple[int, dict row = await cursor.fetchone() return (int(row["version"]), json.loads(row["payload"])) if row else None - async def set_fair_value(self, account_key: str, market_id: str, value: Decimal, *, source: str | None = None) -> None: - async with self._write: - await self._db.execute( - "INSERT INTO fair_values(account_key, market_id, value, source, ts) VALUES(?,?,?,?,?) " - "ON CONFLICT(account_key, market_id) DO UPDATE SET value=excluded.value, source=excluded.source, ts=excluded.ts", - (account_key, market_id, str(value), source, now_ms()), - ) - - async def fair_values(self, account_key: str | None = None) -> dict[tuple[str, str], Decimal]: - sql = "SELECT account_key, market_id, value FROM fair_values" - args: list[Any] = [] - if account_key is not None: - sql += " WHERE account_key=?" - args.append(account_key) - async with self._db.execute(sql, args) as cursor: - return {(row["account_key"], row["market_id"]): Decimal(row["value"]) async for row in cursor} - # -- the single-writer lease --------------------------------------------- async def acquire_lease(self, name: str = "engine", *, ttl_ms: int = 30_000, steal_expired: bool = True) -> Lease: diff --git a/synpath/engine/ledger.py b/synpath/engine/ledger.py index 2d479b9..288cf66 100644 --- a/synpath/engine/ledger.py +++ b/synpath/engine/ledger.py @@ -20,10 +20,11 @@ fill price. Fees are realized when they are charged, never amortized into cost, so a flat book's realized profit is what the venue actually paid. -**Unrealized profit needs a mark, and a mark is a choice.** It comes from -fair values the engine stores per account and instrument (`fair_values.py`), -not from a book this module cannot see. Without a mark a position reports -its cost and no unrealized number, rather than a made-up one. +**Unrealized profit needs a mark.** The engine supplies one per position +(`Engine.marks`): the price the position could be closed at on the live book, +the best bid for a long YES and the best ask for a short (a long NO), else +the last trade, since this module cannot see a book. Without a mark a position reports its cost and +no unrealized number, rather than a made-up one. Settlement closes a position at 1 or 0 and moves the whole remaining quantity into realized. @@ -50,19 +51,19 @@ class PositionState: book: str market_id: str venue: str + # Signed: positive is long YES, negative is short YES (long NO). contracts: Decimal = ZERO - """Signed: positive is long YES, negative is short YES (long NO).""" + # Per contract, of what is currently open; for a short, what was received. average_cost: Decimal = ZERO - """Per contract, of what is currently open; for a short, what was received.""" realized: Decimal = ZERO fees: Decimal = ZERO bought: Decimal = ZERO sold: Decimal = ZERO + # Contracts traded, both directions. volume: Decimal = ZERO - """Contracts traded, both directions.""" + # Contracts held per side (`"yes"`, `"no"`) before netting, as the + # engine's own fills imply: a sell adds to `no`. inventory: dict[str, Decimal] = field(default_factory=dict) - """Contracts held per side (`"yes"`, `"no"`) before netting, as the - engine's own fills imply: a sell adds to `no`.""" resolved: bool = False last_ts: int | None = None @@ -166,18 +167,18 @@ class Rollup: level: Level key: str + # Net signed contracts; for a roll-up above one market it is the sum, + # which nets long one market against short another only if they are the + # same market. contracts: Decimal = ZERO - """Net signed contracts; for a roll-up above one market it is the sum, - which nets long one market against short another only if they are the - same market.""" cost: Decimal = ZERO realized: Decimal = ZERO unrealized: Decimal | None = None fees: Decimal = ZERO volume: Decimal = ZERO positions: int = 0 + # How many of the open positions had a mark; the rest are cost only. marked: int = 0 - """How many of the open positions had a mark; the rest are cost only.""" @property def total(self) -> Decimal: @@ -247,14 +248,14 @@ def open_positions(self) -> list[PositionState]: def position(self, account_key: str, book: str, venue: str, market_id: str) -> PositionState | None: return self.positions.get((account_key, book, market_id)) - def rollup(self, level: Level, marks: dict[tuple[str, str], Decimal] | None = None) -> dict[str, Rollup]: + def rollup(self, level: Level, marks: dict[tuple[str, str, str], Decimal] | None = None) -> dict[str, Rollup]: """Aggregate every position to one of the four levels. - `marks` is keyed by `(account_key, market_id)`, as the journal - stores fair values. + `marks` is keyed like `positions`, `(account_key, book, market_id)`: + a mark is the price that position closes at, which depends on its side. """ out: dict[str, Rollup] = {} - for state in self.positions.values(): + for position_key, state in self.positions.items(): if level in ("market", "instrument"): key = state.market_id elif level == "market": @@ -266,7 +267,7 @@ def rollup(self, level: Level, marks: dict[tuple[str, str], Decimal] | None = No row = out.get(key) if row is None: row = out[key] = Rollup(level=level, key=key) - mark = (marks or {}).get((state.account_key, state.market_id)) + mark = (marks or {}).get(position_key) unreal = state.unrealized(mark) row.contracts += state.contracts row.cost += state.cost @@ -279,7 +280,7 @@ def rollup(self, level: Level, marks: dict[tuple[str, str], Decimal] | None = No row.marked += 1 return out - def total(self, marks: dict[tuple[str, str], Decimal] | None = None) -> Rollup: + def total(self, marks: dict[tuple[str, str, str], Decimal] | None = None) -> Rollup: """The firm's line: everything, every account, every book.""" row = Rollup(level="account", key="*") for part in self.rollup("account", marks).values(): diff --git a/synpath/engine/migration.py b/synpath/engine/migration.py new file mode 100644 index 0000000..68ac640 --- /dev/null +++ b/synpath/engine/migration.py @@ -0,0 +1,19 @@ +"""Run Alembic on the journal's existing, locked SQLAlchemy transaction.""" +from pathlib import Path +from alembic import command +from alembic.config import Config +from sqlalchemy import select +from .models import users +from .database import finish_database_operation + + +async def upgrade(database, user_id): + def run(connection): + configuration = Config() + configuration.set_main_option("script_location", str(Path(__file__).parent / "migrations")) + configuration.attributes.update(connection=connection, user_id=user_id) + command.upgrade(configuration, "head") + owner = connection.execute(select(users.c.user_id)).scalar_one() + if owner != user_id: + raise ValueError("configured user_id differs from the stored execution owner") + await finish_database_operation(database.connection.run_sync(run)) diff --git a/synpath/engine/migrations/README.md b/synpath/engine/migrations/README.md new file mode 100644 index 0000000..19ee990 --- /dev/null +++ b/synpath/engine/migrations/README.md @@ -0,0 +1,17 @@ +# Journal migrations + +Alembic revisions run through the engine's locked SQLAlchemy connection. Current +head is `0006_routed_execution_scope`, logical schema version 11. Configuration, ownership +compatibility and backup/restore behavior live in the [engine README](../README.md). + +- [0001 journal baseline](versions/0001_journal_v6.py): frozen version-6 schema. +- [0002 single user](versions/0002_single_user.py): owner, foreign keys and preserved history. +- [0003 bucket member enabled](versions/0003_bucket_member_enabled.py): `bucket_members.enabled`, existing members on. +- [0004 orders recent](versions/0004_orders_recent.py): `orders_recent` and `orders_market_recent` indexes for listing every order newest first; undated rows take `updated_ts`. +- [0005 drop fair values](versions/0005_drop_fair_values.py): the unused `fair_values` table; positions are marked from the live book. +- [0006 routed execution scope](versions/0006_routed_execution_scope.py): captured parent account membership and venue-neutral routed snapshots/projections; missing historical accounts require a reviewed mapping before upgrade. + +Add a reviewed revision for schema/data changes; never edit deployed revisions or +import mutable runtime definitions into them. Verify both databases and rollback +of failures. Unrestricted autogeneration is not supported by the partial runtime +metadata. Migration execution requires the application's owner/locking context. diff --git a/synpath/engine/migrations/__init__.py b/synpath/engine/migrations/__init__.py new file mode 100644 index 0000000..c0f5af1 --- /dev/null +++ b/synpath/engine/migrations/__init__.py @@ -0,0 +1 @@ +"""Packaged execution journal migrations.""" diff --git a/synpath/engine/migrations/env.py b/synpath/engine/migrations/env.py new file mode 100644 index 0000000..9cef0de --- /dev/null +++ b/synpath/engine/migrations/env.py @@ -0,0 +1,11 @@ +"""Application-managed migrations require its selected connection and owner.""" +from alembic import context +from synpath.engine.models import metadata + +configuration = context.config +connection = configuration.attributes.get("connection") +if connection is None or context.is_offline_mode(): + raise RuntimeError("journal migrations require the application's locked connection and configured user_id") +context.configure(connection=connection, target_metadata=metadata, transactional_ddl=True) +with context.begin_transaction(): + context.run_migrations() diff --git a/synpath/engine/migrations/versions/0001_journal_v6.py b/synpath/engine/migrations/versions/0001_journal_v6.py new file mode 100644 index 0000000..369c339 --- /dev/null +++ b/synpath/engine/migrations/versions/0001_journal_v6.py @@ -0,0 +1,268 @@ +"""Freeze the pre-Alembic schema; adopt compatible preexisting journals.""" +from alembic import op +revision = "0001_journal_v6" +down_revision = None +branch_labels = None +depends_on = None +SCHEMA = """ +CREATE TABLE IF NOT EXISTS submissions ( + client_order_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + account_key TEXT NOT NULL, + venue TEXT NOT NULL, + request TEXT NOT NULL, + effective_request TEXT NOT NULL, + result_id TEXT, + state TEXT NOT NULL DEFAULT 'pending', + error TEXT, + created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS submissions_owner ON submissions(user_id, account_key, created_ts); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS events ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + ts INTEGER NOT NULL, + kind TEXT NOT NULL, + key TEXT, + payload TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS events_kind ON events(kind, seq); +CREATE INDEX IF NOT EXISTS events_key ON events(key, seq); + +CREATE TABLE IF NOT EXISTS intents ( + client_order_id TEXT PRIMARY KEY, + state TEXT NOT NULL, + operation TEXT NOT NULL, + venue TEXT NOT NULL, + account_key TEXT NOT NULL, + market_id TEXT, + order_id TEXT, + target_order_id TEXT, + book TEXT, + trader TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + request TEXT NOT NULL, + detail TEXT, + created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS intents_state ON intents(state, updated_ts); +CREATE INDEX IF NOT EXISTS intents_order ON intents(venue, order_id); + +CREATE TABLE IF NOT EXISTS orders ( + venue TEXT NOT NULL, + id TEXT NOT NULL, + client_order_id TEXT, + account_key TEXT NOT NULL, + market_id TEXT NOT NULL, + side TEXT NOT NULL, + status TEXT NOT NULL, + terminal INTEGER NOT NULL DEFAULT 0, + book TEXT, + trader TEXT, + quantity TEXT NOT NULL, + filled TEXT NOT NULL, + price TEXT, + parent_id TEXT, + payload TEXT NOT NULL, + created_ts INTEGER, + updated_ts INTEGER NOT NULL, + PRIMARY KEY (venue, id) +); +CREATE INDEX IF NOT EXISTS orders_open ON orders(terminal, venue, account_key); +CREATE INDEX IF NOT EXISTS orders_client ON orders(client_order_id); + +CREATE TABLE IF NOT EXISTS fills ( + venue TEXT NOT NULL, + id TEXT NOT NULL, + order_id TEXT NOT NULL, + client_order_id TEXT, + account_key TEXT NOT NULL, + market_id TEXT NOT NULL, + side TEXT NOT NULL, + price TEXT NOT NULL, + quantity TEXT NOT NULL, + fee TEXT, + settlement TEXT NOT NULL, + book TEXT, + trader TEXT, + ts INTEGER NOT NULL, + payload TEXT NOT NULL, + PRIMARY KEY (venue, id) +); +CREATE INDEX IF NOT EXISTS fills_order ON fills(venue, order_id); +CREATE INDEX IF NOT EXISTS fills_ts ON fills(ts); + +CREATE TABLE IF NOT EXISTS managed_orders ( + id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + state TEXT NOT NULL, + account_key TEXT, + market_id TEXT NOT NULL, + book TEXT, + trader TEXT, + payload TEXT NOT NULL, + child_order_id TEXT, + created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS managed_state ON managed_orders(state); + +CREATE TABLE IF NOT EXISTS leases ( + name TEXT PRIMARY KEY, + owner TEXT NOT NULL, + holder TEXT NOT NULL, + acquired_ts INTEGER NOT NULL, + expires_ts INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS config_versions ( + version INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + ts INTEGER NOT NULL, + author TEXT, + payload TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS fair_values ( + account_key TEXT NOT NULL, + market_id TEXT NOT NULL, + value TEXT NOT NULL, + source TEXT, + ts INTEGER NOT NULL, + PRIMARY KEY (account_key, market_id) +); + +CREATE TABLE IF NOT EXISTS buckets ( + id TEXT PRIMARY KEY, + book TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active', + created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS bucket_members ( + bucket_id TEXT NOT NULL REFERENCES buckets(id) ON DELETE CASCADE, + market_id TEXT NOT NULL, + flip INTEGER NOT NULL DEFAULT 0, + position INTEGER NOT NULL, + PRIMARY KEY (bucket_id, market_id) +); + +CREATE TABLE IF NOT EXISTS cursors ( + name TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_ts INTEGER NOT NULL +); + + CREATE TABLE IF NOT EXISTS market_orders ( + venue TEXT NOT NULL, id TEXT NOT NULL, + user_id TEXT NOT NULL CHECK(length(trim(user_id)) > 0), account_key TEXT NOT NULL, + client_order_id TEXT, parent_id TEXT, + market_id TEXT NOT NULL, side TEXT NOT NULL, + quantity TEXT NOT NULL, filled TEXT NOT NULL DEFAULT '0', + remaining TEXT, state TEXT NOT NULL, + time_in_force TEXT NOT NULL, expires_at INTEGER, + reduce_only INTEGER NOT NULL DEFAULT 0, + price TEXT, + request TEXT, snapshot TEXT NOT NULL, + payload_version INTEGER NOT NULL DEFAULT 1, + venue_order_id TEXT, created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL, + PRIMARY KEY (venue, id) + ); + CREATE UNIQUE INDEX IF NOT EXISTS market_orders_client + ON market_orders(user_id, account_key, client_order_id) + WHERE client_order_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS market_orders_state + ON market_orders(user_id, state, updated_ts); + + + CREATE TABLE IF NOT EXISTS limit_orders ( + venue TEXT NOT NULL, id TEXT NOT NULL, + user_id TEXT NOT NULL CHECK(length(trim(user_id)) > 0), account_key TEXT NOT NULL, + client_order_id TEXT, parent_id TEXT, + market_id TEXT NOT NULL, side TEXT NOT NULL, + quantity TEXT NOT NULL, filled TEXT NOT NULL DEFAULT '0', + remaining TEXT, state TEXT NOT NULL, + time_in_force TEXT NOT NULL, expires_at INTEGER, + reduce_only INTEGER NOT NULL DEFAULT 0, + price TEXT CHECK (request IS NULL OR price IS NOT NULL), + request TEXT, snapshot TEXT NOT NULL, + payload_version INTEGER NOT NULL DEFAULT 1, + venue_order_id TEXT, created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL, + PRIMARY KEY (venue, id) + ); + CREATE UNIQUE INDEX IF NOT EXISTS limit_orders_client + ON limit_orders(user_id, account_key, client_order_id) + WHERE client_order_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS limit_orders_state + ON limit_orders(user_id, state, updated_ts); + + + CREATE TABLE IF NOT EXISTS stop_market_orders ( + venue TEXT NOT NULL, id TEXT NOT NULL, + user_id TEXT NOT NULL CHECK(length(trim(user_id)) > 0), account_key TEXT NOT NULL, + client_order_id TEXT, parent_id TEXT, + market_id TEXT NOT NULL, side TEXT NOT NULL, + quantity TEXT NOT NULL, filled TEXT NOT NULL DEFAULT '0', + remaining TEXT, state TEXT NOT NULL, + time_in_force TEXT NOT NULL, expires_at INTEGER, + reduce_only INTEGER NOT NULL DEFAULT 0, + stop_price TEXT CHECK (request IS NULL OR stop_price IS NOT NULL), trigger_source TEXT CHECK (request IS NULL OR trigger_source IS NOT NULL), + request TEXT, snapshot TEXT NOT NULL, + payload_version INTEGER NOT NULL DEFAULT 1, + venue_order_id TEXT, created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL, + PRIMARY KEY (venue, id) + ); + CREATE UNIQUE INDEX IF NOT EXISTS stop_market_orders_client + ON stop_market_orders(user_id, account_key, client_order_id) + WHERE client_order_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS stop_market_orders_state + ON stop_market_orders(user_id, state, updated_ts); + + + CREATE TABLE IF NOT EXISTS stop_limit_orders ( + venue TEXT NOT NULL, id TEXT NOT NULL, + user_id TEXT NOT NULL CHECK(length(trim(user_id)) > 0), account_key TEXT NOT NULL, + client_order_id TEXT, parent_id TEXT, + market_id TEXT NOT NULL, side TEXT NOT NULL, + quantity TEXT NOT NULL, filled TEXT NOT NULL DEFAULT '0', + remaining TEXT, state TEXT NOT NULL, + time_in_force TEXT NOT NULL, expires_at INTEGER, + reduce_only INTEGER NOT NULL DEFAULT 0, + price TEXT CHECK (request IS NULL OR price IS NOT NULL), stop_price TEXT CHECK (request IS NULL OR stop_price IS NOT NULL), trigger_source TEXT CHECK (request IS NULL OR trigger_source IS NOT NULL), + request TEXT, snapshot TEXT NOT NULL, + payload_version INTEGER NOT NULL DEFAULT 1, + venue_order_id TEXT, created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL, + PRIMARY KEY (venue, id) + ); + CREATE UNIQUE INDEX IF NOT EXISTS stop_limit_orders_client + ON stop_limit_orders(user_id, account_key, client_order_id) + WHERE client_order_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS stop_limit_orders_state + ON stop_limit_orders(user_id, state, updated_ts); + """ + +def upgrade(): + source = SCHEMA + if op.get_bind().dialect.name == "postgresql": + import re + source = source.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "BIGSERIAL PRIMARY KEY") + source = re.sub(r"\bINTEGER\b", "BIGINT", source) + for statement in source.split(";"): + if statement.strip(): + op.execute(statement) + +def downgrade(): + raise RuntimeError("baseline downgrade is destructive; restore a verified backup") diff --git a/synpath/engine/migrations/versions/0002_single_user.py b/synpath/engine/migrations/versions/0002_single_user.py new file mode 100644 index 0000000..e3f813f --- /dev/null +++ b/synpath/engine/migrations/versions/0002_single_user.py @@ -0,0 +1,68 @@ +"""Seed one owner and reference it from all authoritative instructions.""" +import json +from alembic import op, context +import sqlalchemy as sa +# Revision definitions are frozen; future runtime models must not rewrite history. +metadata = sa.MetaData() +users = sa.Table("users", metadata, + sa.Column("user_id", sa.Text, primary_key=True, nullable=False), + sa.Column("singleton", sa.Integer, nullable=False, server_default="1", unique=True), + sa.CheckConstraint("singleton = 1", name="users_single_owner"), + sa.CheckConstraint("length(trim(user_id)) > 0", name="users_nonempty_id")) +historical_order_records = sa.Table("historical_order_records", metadata, + sa.Column("kind", sa.Text, primary_key=True), + sa.Column("venue", sa.Text, primary_key=True), + sa.Column("id", sa.Text, primary_key=True), + sa.Column("user_id", sa.Text, nullable=False), + sa.Column("record", sa.Text, nullable=False)) + +revision = "0002_single_user" +down_revision = "0001_journal_v6" +branch_labels = None +depends_on = None +ORDER_TABLES = {"market": "market_orders", "limit": "limit_orders", "stop_market": "stop_market_orders", "stop_limit": "stop_limit_orders"} + + +def upgrade(): + connection = op.get_bind() + owner = context.config.attributes["user_id"] + _check_instruction_ownership(connection, owner) + users.create(connection) + historical_order_records.create(connection) + connection.execute(users.insert().values(user_id=owner)) + for kind, table_name in ORDER_TABLES.items(): + table = sa.Table(table_name, sa.MetaData(), autoload_with=connection) + _preserve_observations(connection, table, kind, owner) + # Named reflected checks are retained when SQLite rebuilds the table. + checks = sorted((constraint for constraint in table.constraints if isinstance(constraint, sa.CheckConstraint)), + key=lambda constraint: str(constraint.sqltext)) + for index, constraint in enumerate(checks): + if constraint.name is None: + constraint.name = f"{table_name}_check_{index}" + with op.batch_alter_table(table_name, copy_from=table) as batch: + batch.create_foreign_key(f"{table_name}_user", "users", ["user_id"], ["user_id"]) + submissions = sa.Table("submissions", sa.MetaData(), autoload_with=connection) + with op.batch_alter_table("submissions", copy_from=submissions) as batch: + batch.create_foreign_key("submissions_user", "users", ["user_id"], ["user_id"]) + connection.execute(sa.text("UPDATE meta SET value='7' WHERE key='schema_version'")) + + +def _check_instruction_ownership(connection, owner): + for table_name in (*ORDER_TABLES.values(), "submissions"): + table = sa.Table(table_name, sa.MetaData(), autoload_with=connection) + query = sa.select(table.c.user_id).where(table.c.user_id != owner) + if table_name != "submissions": + query = query.where(table.c.request.is_not(None)) + if connection.execute(query.limit(1)).first(): + raise ValueError("existing instruction ownership conflicts with configured user_id; explicit ownership review required") + + +def _preserve_observations(connection, table, kind, owner): + rows = connection.execute(sa.select(table).where(table.c.user_id != owner, table.c.request.is_(None))).mappings().all() + for row in rows: + connection.execute(historical_order_records.insert().values(kind=kind, venue=row["venue"], id=row["id"], user_id=row["user_id"], record=json.dumps(dict(row)))) + connection.execute(table.delete().where(table.c.venue == row["venue"], table.c.id == row["id"])) + + +def downgrade(): + raise RuntimeError("owner migration downgrade requires an explicit backup/restore plan") diff --git a/synpath/engine/migrations/versions/0003_bucket_member_enabled.py b/synpath/engine/migrations/versions/0003_bucket_member_enabled.py new file mode 100644 index 0000000..a4358e9 --- /dev/null +++ b/synpath/engine/migrations/versions/0003_bucket_member_enabled.py @@ -0,0 +1,23 @@ +"""A bucket member can be switched off: `bucket_members.enabled`, on by default.""" +from alembic import op +import sqlalchemy as sa + +revision = "0003_bucket_member_enabled" +down_revision = "0002_single_user" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + columns = {column["name"] for column in sa.inspect(connection).get_columns("bucket_members")} + if "enabled" not in columns: + # Every member stored before this revision was routed, so it stays on. + op.add_column("bucket_members", sa.Column("enabled", sa.Integer, nullable=False, server_default="1")) + connection.execute(sa.text("UPDATE meta SET value='8' WHERE key='schema_version'")) + + +def downgrade(): + with op.batch_alter_table("bucket_members") as batch: + batch.drop_column("enabled") + op.get_bind().execute(sa.text("UPDATE meta SET value='7' WHERE key='schema_version'")) diff --git a/synpath/engine/migrations/versions/0004_orders_recent.py b/synpath/engine/migrations/versions/0004_orders_recent.py new file mode 100644 index 0000000..bc65209 --- /dev/null +++ b/synpath/engine/migrations/versions/0004_orders_recent.py @@ -0,0 +1,30 @@ +"""Orders in every state, newest first: `orders_recent` and `orders_market_recent`. + +`GET /trading/orders?scope=all` pages the `orders` table by `(created_ts, +venue, id)` descending, optionally for one market. Every row written by the +journal has a `created_ts`; a row from before that guarantee takes its +`updated_ts`, the closest time on record, so it sorts and pages like the rest. +""" +from alembic import op +import sqlalchemy as sa + +revision = "0004_orders_recent" +down_revision = "0003_bucket_member_enabled" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + connection.execute(sa.text("UPDATE orders SET created_ts = updated_ts WHERE created_ts IS NULL")) + connection.execute(sa.text("CREATE INDEX IF NOT EXISTS orders_recent ON orders(created_ts, venue, id)")) + connection.execute(sa.text( + "CREATE INDEX IF NOT EXISTS orders_market_recent ON orders(market_id, created_ts, venue, id)")) + connection.execute(sa.text("UPDATE meta SET value='9' WHERE key='schema_version'")) + + +def downgrade(): + connection = op.get_bind() + connection.execute(sa.text("DROP INDEX IF EXISTS orders_market_recent")) + connection.execute(sa.text("DROP INDEX IF EXISTS orders_recent")) + connection.execute(sa.text("UPDATE meta SET value='8' WHERE key='schema_version'")) diff --git a/synpath/engine/migrations/versions/0005_drop_fair_values.py b/synpath/engine/migrations/versions/0005_drop_fair_values.py new file mode 100644 index 0000000..03a94e3 --- /dev/null +++ b/synpath/engine/migrations/versions/0005_drop_fair_values.py @@ -0,0 +1,27 @@ +"""Fair values are gone: drop the `fair_values` table. + +A position is marked from the live book by the engine (`Engine.marks`, the +price it could be closed at), never from a stored value, so nothing reads or +writes this table any more. Downgrade recreates it empty. +""" +from alembic import op +import sqlalchemy as sa + +revision = "0005_drop_fair_values" +down_revision = "0004_orders_recent" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + connection.execute(sa.text("DROP TABLE IF EXISTS fair_values")) + connection.execute(sa.text("UPDATE meta SET value='10' WHERE key='schema_version'")) + + +def downgrade(): + connection = op.get_bind() + connection.execute(sa.text( + "CREATE TABLE IF NOT EXISTS fair_values (account_key TEXT NOT NULL, market_id TEXT NOT NULL, " + "value TEXT NOT NULL, source TEXT, ts INTEGER NOT NULL, PRIMARY KEY (account_key, market_id))")) + connection.execute(sa.text("UPDATE meta SET value='9' WHERE key='schema_version'")) diff --git a/synpath/engine/migrations/versions/0006_routed_execution_scope.py b/synpath/engine/migrations/versions/0006_routed_execution_scope.py new file mode 100644 index 0000000..e5d5177 --- /dev/null +++ b/synpath/engine/migrations/versions/0006_routed_execution_scope.py @@ -0,0 +1,98 @@ +"""Replace nominal parent attribution with captured execution membership. + +Migration definitions are frozen. Missing historical accounts require an +explicit mapping in meta['legacy_execution_accounts'] keyed by parent ID, +then venue. Never substitute the current process's configured accounts. +""" +import json + +from alembic import op +import sqlalchemy as sa + +revision = "0006_routed_execution_scope" +down_revision = "0005_drop_fair_values" +branch_labels = None +depends_on = None +NAMESPACE = "__managed__" + + +def account_key(account): + return f"{account['venue']}:{account.get('name', 'default')}" + ( + f":{account['subaccount']}" if account.get('subaccount') else "") + + +def upgrade(): + connection = op.get_bind() + op.create_table("parent_execution_accounts", + sa.Column("parent_id", sa.Text, sa.ForeignKey("managed_orders.id"), primary_key=True), + sa.Column("venue", sa.Text, primary_key=True), + sa.Column("account_key", sa.Text, nullable=False)) + op.create_index("parent_execution_venue", "parent_execution_accounts", ["venue", "parent_id"]) + op.create_index("parent_execution_account", "parent_execution_accounts", ["account_key", "parent_id"]) + metadata = sa.MetaData() + parents = sa.Table("managed_orders", metadata, autoload_with=connection) + orders = sa.Table("orders", metadata, autoload_with=connection) + intents = sa.Table("intents", metadata, autoload_with=connection) + membership = sa.Table("parent_execution_accounts", metadata, autoload_with=connection) + meta = sa.Table("meta", metadata, autoload_with=connection) + supplied = connection.execute(sa.select(meta.c.value).where(meta.c.key == "legacy_execution_accounts")).scalar() + mappings = json.loads(supplied) if supplied else {} + historical_intent_accounts = {} + for payload in connection.execute(sa.select(intents.c.request)).scalars(): + child_request = json.loads(payload) + parent_id = (child_request.get("tags") or {}).get("parent") + if parent_id: + historical_intent_accounts.setdefault(parent_id, []).append(child_request.get("account")) + for row in connection.execute(sa.select(parents).where(parents.c.kind == "routed_limit")).mappings().all(): + snapshot = json.loads(row["payload"]) + request = snapshot["request"] + venues = {member["market_id"].split(":", 1)[0] + for member in request["params"]["bucket"]["members"] if member.get("enabled", True)} + accounts = {} + + def remember(account): + if not account or account.get("venue") not in venues: + return + venue = account["venue"] + if venue in accounts and account_key(accounts[venue]) != account_key(account): + raise ValueError(f"{row['id']}: conflicting historical execution accounts; explicit review required") + accounts[venue] = account + + remember(snapshot.get("account")) + children = connection.execute(sa.select(orders.c.payload).where(orders.c.parent_id == row["id"])).scalars() + for payload in children: + remember(json.loads(payload).get("account")) + # Uncertain children may have an intent but no returned order yet. + for account in historical_intent_accounts.get(row["id"], []): + remember(account) + for venue, account in mappings.get(row["id"], {}).items(): + if account.get("venue") != venue or venue not in venues: + raise ValueError(f"{row['id']}: invalid explicit execution account mapping") + remember(account) + if set(accounts) != venues: + missing = ", ".join(sorted(venues - set(accounts))) + raise ValueError(f"{row['id']}: historical execution accounts missing for {missing}; " + "review and populate meta.legacy_execution_accounts before upgrade") + accounts = dict(sorted(accounts.items())) + snapshot["legacy_execution_identity"] = {"venue": snapshot.get("venue"), "account": snapshot.get("account")} + snapshot.update(venue=None, account=None, execution_accounts=accounts) + request["account"] = None + request["params"]["execution_accounts"] = accounts + connection.execute(parents.update().where(parents.c.id == row["id"]).values( + account_key=None, payload=json.dumps(snapshot))) + for venue, account in accounts.items(): + connection.execute(membership.insert().values(parent_id=row["id"], venue=venue, account_key=account_key(account))) + projections = connection.execute(sa.select(orders).where( + orders.c.id == row["id"], orders.c.parent_id.is_(None))).mappings().all() + for projection in projections: + payload = json.loads(projection["payload"]) + if payload.get("held_by") != "engine": + continue + payload.update(venue=None, account=None, execution_accounts=accounts) + connection.execute(orders.update().where(orders.c.venue == projection["venue"], orders.c.id == row["id"]).values( + venue=NAMESPACE, account_key=NAMESPACE, payload=json.dumps(payload))) + connection.execute(meta.update().where(meta.c.key == "schema_version").values(value="11")) + + +def downgrade(): + raise RuntimeError("execution-scope downgrade requires an explicit compatible backup/restore plan") diff --git a/synpath/engine/migrations/versions/__init__.py b/synpath/engine/migrations/versions/__init__.py new file mode 100644 index 0000000..c0f5af1 --- /dev/null +++ b/synpath/engine/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Packaged execution journal migrations.""" diff --git a/synpath/engine/models.py b/synpath/engine/models.py new file mode 100644 index 0000000..3113baf --- /dev/null +++ b/synpath/engine/models.py @@ -0,0 +1,19 @@ +"""SQLAlchemy schema objects for execution ownership and preserved history.""" +from sqlalchemy import Column, Integer, Text, Table, MetaData, CheckConstraint + +metadata = MetaData() +users = Table( + "users", metadata, + Column("user_id", Text, primary_key=True, nullable=False), + Column("singleton", Integer, nullable=False, server_default="1", unique=True), + CheckConstraint("singleton = 1", name="users_single_owner"), + CheckConstraint("length(trim(user_id)) > 0", name="users_nonempty_id"), +) +historical_order_records = Table( + "historical_order_records", metadata, + Column("kind", Text, primary_key=True), + Column("venue", Text, primary_key=True), + Column("id", Text, primary_key=True), + Column("user_id", Text, nullable=False), + Column("record", Text, nullable=False), +) diff --git a/synpath/engine/order_log.py b/synpath/engine/order_log.py new file mode 100644 index 0000000..132926f --- /dev/null +++ b/synpath/engine/order_log.py @@ -0,0 +1,188 @@ +"""The order log: every action on an order, as the journal recorded it. + +The journal's append-only `events` already hold each step an order goes +through, under several keys: the instruction saved before anything is sent +(`intent.planned`, keyed by `client_order_id`, or `cancel::` for +a cancel), the send and its outcome (`intent.sending`, `intent.sent`, +`intent.failed`, `intent.rejected`, `submission.*`), the venue's record +(`order.accepted`, `order.updated`, `order.canceled`, ..., keyed +`:`), an engine-held parent's life (`managed.*`, keyed by its +id and by `:`), fills (`fill.new`) and risk refusals +(`risk.rejected`). This module reads them back as one log: + +* **One shape per entry** (`entry`): what happened (`kind`), when, to which + order, and the order's fields as the event carried them. +* **Repeats folded** (`fold`): an engine-held parent re-records its state + while it waits, and the venue's record repeats on every poll; a run of the + same kind (and, for a venue order, status and filled quantity) for one + order is shown once, at its first time. +* **Facts carried forward** (`fold`): an event that leaves out the order's + market, side, outcome or action (a trigger, a cancel's instruction) takes + them from the order's earlier events. +* **One order's family** (`order_keys`): an order's log includes its + cancel, its fills, and for an engine-held parent its children's. + +Read only: nothing here changes the journal. +""" +from __future__ import annotations + +from typing import Any, Iterable + +from .storage_types import JournalEvent + +ORDER_EVENT_PREFIXES = ("intent.", "submission.", "order.", "managed.", "fill.") +ORDER_EVENT_KINDS = ("risk.rejected",) +# Engine-wide, not about one order: the journal view shows them. +NOT_ORDER_KINDS = frozenset({"managed.restored"}) +RAW_BATCH = 1_000 +# Raw events one page reads at most, however many fold away. +MOST_RAW_EVENTS = 50_000 + +# An order's fields a later event of it may leave out. +CARRIED = ("market_id", "venue", "side", "outcome", "action") +ORDER_FIELDS = ("market_id", "side", "outcome", "action", "type", "quantity", "filled", "price", "stop_price") + + +def entry(event: JournalEvent) -> dict[str, Any]: + """One journal event as a log entry.""" + payload = event.payload if isinstance(event.payload, dict) else {} + request = payload.get("request") if isinstance(payload.get("request"), dict) else {} + row: dict[str, Any] = {"seq": event.seq, "ts": event.ts, "kind": event.kind, "order_id": None, + "client_order_id": None, "parent_id": None, "venue": None, "status": None, + "trigger_price": None, "rule": None, "detail": None, + **{name: None for name in ORDER_FIELDS}} + key = event.key or "" + if key.startswith("cancel:"): + key = key.removeprefix("cancel:") # a cancel's intent, keyed cancel:: + venue, _, rest = key.partition(":") + if "id" in payload and ("status" in payload or "state" in payload): + # An order or an engine-held parent's snapshot. + row.update({name: payload.get(name) for name in ORDER_FIELDS if payload.get(name) is not None}) + row.update({name: request.get(name) for name in ORDER_FIELDS if row.get(name) is None and request.get(name) is not None}) + row["order_id"], row["client_order_id"] = payload.get("id"), payload.get("client_order_id") + row["parent_id"], row["venue"] = payload.get("parent_id"), payload.get("venue") + row["status"] = payload.get("status") or payload.get("state") + row["type"] = row["type"] or payload.get("kind") + row["detail"] = (payload.get("info") or {}).get("detail") if isinstance(payload.get("info"), dict) else payload.get("detail") + elif event.kind == "fill.new": + row.update({name: payload.get(name) for name in ("market_id", "side", "outcome", "action", "price", "quantity")}) + row["order_id"], row["client_order_id"] = payload.get("order_id"), payload.get("client_order_id") + row["venue"], row["detail"] = payload.get("venue"), payload.get("liquidity") + elif request: + # An instruction saved before sending: a create or a cancel. + row.update({name: request.get(name) for name in ORDER_FIELDS if request.get(name) is not None}) + row["client_order_id"] = request.get("client_order_id") or event.key + row["order_id"] = payload.get("target_order_id") + row["venue"] = payload.get("venue") + row["detail"] = payload.get("operation") + else: + row["order_id"] = payload.get("parent_id") or payload.get("order_id") + row["client_order_id"] = payload.get("client_order_id") or payload.get("child_client_order_id") + row["type"] = payload.get("kind") if event.kind.startswith("managed.") else None + row["filled"] = payload.get("filled") + row["stop_price"] = payload.get("stop_price") + row["trigger_price"] = payload.get("trigger_price") + row["market_id"] = payload.get("market_id") + row["rule"] = payload.get("rule") + row["detail"] = payload.get("detail") or payload.get("error") or payload.get("message") + if event.kind.startswith("intent.") and not row["order_id"] and (event.key or "").startswith("cancel:"): + row["venue"], row["order_id"] = venue, rest + elif event.kind.startswith("intent.") or event.kind.startswith("submission.") or event.kind.endswith(".failed") \ + or event.kind.endswith(".rejected"): + row["client_order_id"] = row["client_order_id"] or event.key + if row["venue"] is None and rest and venue: + row["venue"] = venue + if row["order_id"] is None and ":" in key: + row["order_id"] = rest or None + return row + + +def fold(entries_oldest_first: Iterable[dict[str, Any]]) -> list[dict[str, Any]]: + """Drop an entry that repeats the last one kept for its order: the same + kind, status and filled quantity. Every change is kept.""" + kept: list[dict[str, Any]] = [] + last: dict[str, tuple[Any, ...]] = {} + known: dict[str, dict[str, Any]] = {} + for row in entries_oldest_first: + who = str(row.get("order_id") or row.get("client_order_id") or row["seq"]) + # What an earlier event of this order said and this one leaves out + # (a trigger, a cancel's instruction): carried forward, never guessed. + facts = known.setdefault(who, {}) + for name in CARRIED: + if row.get(name) is None and facts.get(name) is not None: + row[name] = facts[name] + elif row.get(name) is not None: + facts[name] = row[name] + # An engine-held parent records each state twice (its snapshot and + # its order view), so its kind alone says what changed. + signature = (row["kind"],) if row["kind"].startswith("managed.") else (row["kind"], row.get("status"), + row.get("filled")) + if last.get(who) == signature: + continue + if row["kind"] != "managed.created": + # Creation is not a state: a parent's waiting on either side of it is one wait. + last[who] = signature + kept.append(row) + return kept + + +async def order_keys(engine: Any, order_id: str) -> list[str]: + """Every journal key one order's log is under: the order, its client + order id, its cancel, and an engine-held parent's children and theirs.""" + keys: list[str] = [] + family = [order_id] + seen: set[str] = set() + while family: + current = family.pop() + if current in seen: + continue + seen.add(current) + keys.append(current) + found = [] + for venue in engine.adapters: + order = await engine.journal.order(venue, current) + if order is not None: + found.append(order) + managed = await engine.journal.managed_one(current) + if managed is not None: + _, snapshot = managed + venue = snapshot.get("venue") + keys += [f"{venue}:{current}"] if venue else [f"__managed__:{current}"] + legacy_venue = (snapshot.get("legacy_execution_identity") or {}).get("venue") + if legacy_venue: + keys.append(f"{legacy_venue}:{current}") + client = snapshot.get("client_order_id") or (snapshot.get("request") or {}).get("client_order_id") + keys += [client] if client else [] + family += [str(child.get("order_id") if isinstance(child, dict) else child) + for child in snapshot.get("children") or []] + for order in found: + keys += [f"{order.venue}:{order.id}", f"cancel:{order.venue}:{order.id}"] + keys += [order.client_order_id] if order.client_order_id else [] + family += [str(child) for child in (order.info or {}).get("children") or []] + return list(dict.fromkeys(key for key in keys if key)) + + +async def order_log(engine: Any, *, order_id: str | None = None, limit: int = 200, + before: int | None = None, allow_event=None) -> tuple[list[dict[str, Any]], int | None]: + """The log newest first, for one order (and its family) or the whole + account, with the `before` cursor for the next page (`None` at the end). + Raw events are read in batches until the folded page is full: a waiting + stop leaves thousands of repeats that fold to one entry.""" + keys = await order_keys(engine, order_id) if order_id else None + events: list[JournalEvent] = [] + cursor, exhausted = before, False + while True: + batch = await engine.journal.events_page(kinds=ORDER_EVENT_KINDS, prefixes=ORDER_EVENT_PREFIXES, keys=keys, + before=cursor, limit=RAW_BATCH) + events += [event for event in batch if event.kind not in NOT_ORDER_KINDS + and (allow_event is None or await allow_event(event))] + exhausted = len(batch) < RAW_BATCH + if batch: + cursor = batch[-1].seq + rows = fold(entry(event) for event in reversed(events)) + if exhausted or len(rows) > limit or len(events) >= MOST_RAW_EVENTS: + break + rows.reverse() + page = rows[:limit] + more = len(rows) > limit or not exhausted + return page, (page[-1]["seq"] if page and more else None) diff --git a/synpath/engine/order_storage.py b/synpath/engine/order_storage.py new file mode 100644 index 0000000..b6d51ab --- /dev/null +++ b/synpath/engine/order_storage.py @@ -0,0 +1,168 @@ +"""Shared order-type records, written inside the journal's transaction. + +The four instruction tables own their type's request and durable state. +`orders` remains the shared execution projection used by the existing +ledger, reconciliation, and API. These writes never commit independently: +a type row and its journal event either both happen or neither happens. +""" +from __future__ import annotations + +import json +from decimal import Decimal +from typing import Any + +from ..trading.types import Account, Order, OrderRequest + +UNATTRIBUTED_USER = "legacy:unattributed" +ORDER_TABLES = { + "market": "market_orders", + "limit": "limit_orders", + "stop_market": "stop_market_orders", + "stop_limit": "stop_limit_orders", +} +TYPE_COLUMNS = { + "market": {"price": "TEXT"}, + "limit": {"price": "TEXT CHECK (request IS NULL OR price IS NOT NULL)"}, + "stop_market": {"stop_price": "TEXT CHECK (request IS NULL OR stop_price IS NOT NULL)", "trigger_source": "TEXT CHECK (request IS NULL OR trigger_source IS NOT NULL)"}, + "stop_limit": {"price": "TEXT CHECK (request IS NULL OR price IS NOT NULL)", "stop_price": "TEXT CHECK (request IS NULL OR stop_price IS NOT NULL)", "trigger_source": "TEXT CHECK (request IS NULL OR trigger_source IS NOT NULL)"}, +} + + +def _dump(value: Any) -> str: + return json.dumps(value, default=str, separators=(",", ":")) + + +def schema() -> str: + """Only identifiers from the fixed order registry enter generated SQL.""" + statements = [] + for kind, table in ORDER_TABLES.items(): + columns = ", ".join(f"{name} {definition}" for name, definition in TYPE_COLUMNS[kind].items()) + statements.append(f""" + CREATE TABLE IF NOT EXISTS {table} ( + venue TEXT NOT NULL, id TEXT NOT NULL, + user_id TEXT NOT NULL CHECK(length(trim(user_id)) > 0), account_key TEXT NOT NULL, + client_order_id TEXT, parent_id TEXT, + market_id TEXT NOT NULL, side TEXT NOT NULL, + quantity TEXT NOT NULL, filled TEXT NOT NULL DEFAULT '0', + remaining TEXT, state TEXT NOT NULL, + time_in_force TEXT NOT NULL, expires_at INTEGER, + reduce_only INTEGER NOT NULL DEFAULT 0, + {columns}, + request TEXT, snapshot TEXT NOT NULL, + payload_version INTEGER NOT NULL DEFAULT 1, + venue_order_id TEXT, created_ts INTEGER NOT NULL, + updated_ts INTEGER NOT NULL, + PRIMARY KEY (venue, id) + ); + CREATE UNIQUE INDEX IF NOT EXISTS {table}_client + ON {table}(user_id, account_key, client_order_id) + WHERE client_order_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS {table}_state + ON {table}(user_id, state, updated_ts); + """) + return "\n".join(statements) + + +class OrderRecords: + def __init__(self, connection: Any, *, user_id: str | None = None): + self.connection = connection + self.user_id = user_id + + async def write_request(self, request: OrderRequest, *, venue: str, account_key: str, + client_order_id: str, user_id: str, stamp: int, state: str = "planned") -> None: + body = {**request.model_dump(mode="json"), "client_order_id": client_order_id, "user_id": user_id} + await self._write(request.type.value, venue, client_order_id, user_id, account_key, body, + state=state, request=body, snapshot=body, stamp=stamp) + + async def write_order(self, order: Order, *, stamp: int, user_id: str | None = None) -> None: + kind = order.type.value + if kind not in ORDER_TABLES: + return + account_key = order.account.key if order.account else f"{order.venue}:default" + owner = order.user_id or user_id or UNATTRIBUTED_USER + application_id = order.id + if order.client_order_id: + async with self.connection.execute( + f"SELECT id FROM {ORDER_TABLES[kind]} WHERE user_id=? AND account_key=? AND client_order_id=?", + (owner, account_key, order.client_order_id), + ) as cursor: + existing = await cursor.fetchone() + if existing: + application_id = existing["id"] + body = order.model_dump(mode="json") + await self._write(kind, order.venue, application_id, owner, account_key, body, + state=order.status.value, snapshot=body, stamp=stamp, + venue_order_id=order.id) + + async def write_managed(self, snapshot: dict[str, Any], *, user_id: str, stamp: int) -> None: + request = snapshot.get("request") or {} + kind = snapshot.get("kind", "") + if kind not in ORDER_TABLES: + return + account = Account.model_validate(snapshot["account"]) + account_key = account.key + await self._write(kind, snapshot["venue"], snapshot["id"], request.get("user_id") or user_id, + account_key, {**request, "filled": snapshot.get("filled", "0"), + "remaining": str(max(Decimal("0"), Decimal(request["quantity"]) - Decimal(snapshot.get("filled", "0"))))}, + state=snapshot["state"], request=request, + snapshot=snapshot, stamp=stamp) + + async def _write(self, kind: str, venue: str, application_id: str, user_id: str, + account_key: str, body: dict[str, Any], *, state: str, + snapshot: dict[str, Any], stamp: int, request: dict[str, Any] | None = None, + venue_order_id: str | None = None) -> None: + table = ORDER_TABLES.get(kind) + if table is None: + return + columns = ["venue", "id", "user_id", "account_key", "client_order_id", "parent_id", + "market_id", "side", "quantity", "filled", "remaining", "state", + "time_in_force", "expires_at", "reduce_only", *TYPE_COLUMNS[kind], + "request", "snapshot", "venue_order_id", "created_ts", "updated_ts"] + values = [venue, application_id, user_id, account_key, body.get("client_order_id"), + body.get("parent_id") or (body.get("tags") or {}).get("parent"), + body["market_id"], body["side"], str(body["quantity"]), str(body.get("filled", "0")), + body.get("remaining"), state, body.get("time_in_force", "gtc"), body.get("expires_at"), + # Left from before orders said their outcome and action (now in + # `request` and `snapshot`); always 0 for an order written since. + int(body.get("reduce_only", False))] + values.extend(body.get(name, "touch" if name == "trigger_source" else None) + for name in TYPE_COLUMNS[kind]) + values.extend([_dump(request) if request is not None else None, _dump(snapshot), venue_order_id, + int(body.get("created_at") or snapshot.get("created_ms") or stamp), stamp]) + if self.user_id is not None and user_id != self.user_id and request is None: + record = {**dict(zip(columns, values)), "payload_version": 1} + await self.connection.execute( + "INSERT INTO historical_order_records(kind,venue,id,user_id,record) VALUES(?,?,?,?,?) " + "ON CONFLICT(kind,venue,id) DO UPDATE SET record=excluded.record", + (kind, venue, application_id, user_id, _dump(record)), + ) + return + # The submitted instruction and owner never change when a venue result + # arrives. Its mutable execution state is a separate snapshot. + updates = [f"{name}=excluded.{name}" for name in + ("filled", "remaining", "state", "snapshot", "updated_ts")] + updates += [f"request=COALESCE({table}.request, excluded.request)", + f"venue_order_id=COALESCE(excluded.venue_order_id, {table}.venue_order_id)"] + await self.connection.execute( + f"INSERT INTO {table}({','.join(columns)}) VALUES({','.join('?' for _ in columns)}) " + f"ON CONFLICT(venue,id) DO UPDATE SET {','.join(updates)}", values, + ) + + async def get(self, kind: str, venue: str, application_id: str) -> dict[str, Any] | None: + table = ORDER_TABLES.get(kind) + if table is None: + raise ValueError(f"no instruction table for order type {kind!r}") + async with self.connection.execute(f"SELECT * FROM {table} WHERE venue=? AND id=?", + (venue, application_id)) as cursor: + row = await cursor.fetchone() + if row is None: + async with self.connection.execute("SELECT record FROM historical_order_records WHERE kind=? AND venue=? AND id=?", + (kind, venue, application_id)) as cursor: + historical = await cursor.fetchone() + if historical is None: + return None + row = json.loads(historical["record"]) + result = dict(row) + result["request"] = json.loads(result["request"]) if result["request"] else None + result["snapshot"] = json.loads(result["snapshot"]) + return result diff --git a/synpath/engine/orders/__init__.py b/synpath/engine/orders/__init__.py index b790adf..8b429af 100644 --- a/synpath/engine/orders/__init__.py +++ b/synpath/engine/orders/__init__.py @@ -27,7 +27,7 @@ from .base import Child, Context, ManagedOrder, State from .day import as_gtd, session_expiry from .iceberg import Iceberg -from .manager import REGISTRY, ManagedOrders, register +from .manager import REGISTRY, ManagedOrderController, register from .oco import OCO, Bracket from .peg import Peg from .routed import RoutedLimit @@ -36,7 +36,7 @@ from .twap import TWAP __all__ = [ - "ManagedOrder", "ManagedOrders", "Context", "Child", "State", "register", "REGISTRY", + "ManagedOrder", "ManagedOrderController", "Context", "Child", "State", "register", "REGISTRY", "StopMarket", "StopLimit", "TrailingStop", "Iceberg", "OCO", "Bracket", "TWAP", "Peg", "MarketOrder", "SmartTaker", "as_gtd", "session_expiry", ] diff --git a/synpath/engine/orders/base.py b/synpath/engine/orders/base.py index 27be9e3..be8533e 100644 --- a/synpath/engine/orders/base.py +++ b/synpath/engine/orders/base.py @@ -20,7 +20,7 @@ of which can be refused on its own. **Events arrive, the parent decides.** `on_book`, `on_trade`, `on_fill`, -`on_child`, `on_timer` and `on_halt` are the only ways in. A type that needs +`on_child_order_updated`, `on_timer` and `on_halt` are the only ways in. A type that needs the touch reads it from the book the engine keeps; one that needs a clock gets `on_timer` about once a second, which is also what makes a restart harmless: the timer does not care how long it has been away. @@ -40,8 +40,9 @@ from decimal import Decimal from typing import Any, Iterable, Literal, Mapping +from ..storage_types import Intent, IntentState from ...trading.types import ( - Account, Fill, HeldBy, Order, OrderRequest, OrderStatus, OrderType, Side, TimeInForce, + Account, Fill, HeldBy, Order, OrderRequest, OrderStatus, OrderType, Precision, Side, TimeInForce, ) log = logging.getLogger("synpath.engine.orders") @@ -86,6 +87,7 @@ class Child: venue: str quantity: Decimal price: Decimal | None = None + average_price: Decimal | None = None filled: Decimal = ZERO fee: Decimal = ZERO status: str = "open" @@ -98,7 +100,8 @@ def live(self) -> bool: def to_dict(self) -> dict[str, Any]: return { "order_id": self.order_id, "venue": self.venue, "quantity": str(self.quantity), - "price": str(self.price) if self.price is not None else None, "filled": str(self.filled), + "price": str(self.price) if self.price is not None else None, + "average_price": str(self.average_price) if self.average_price is not None else None, "filled": str(self.filled), "fee": str(self.fee), "status": self.status, "created_ms": self.created_ms, } @@ -107,7 +110,7 @@ def to_dict(self) -> dict[str, Any]: def of(cls, row: Mapping[str, Any]) -> "Child": return cls( order_id=row["order_id"], venue=row["venue"], quantity=D(row["quantity"]), price=D(row.get("price")), - filled=D(row.get("filled"), ZERO), fee=D(row.get("fee"), ZERO), status=row.get("status", "open"), + average_price=D(row.get("average_price")), filled=D(row.get("filled"), ZERO), fee=D(row.get("fee"), ZERO), status=row.get("status", "open"), created_ms=int(row.get("created_ms") or now_ms()), ) @@ -136,6 +139,9 @@ def book(self, market_id: str | None = None) -> Any | None: book = self.engine.books.get(market_id or self.parent.market_id) if book is not None and getattr(book, "ready", True) is False: return None + if book is not None and book.best_bid is not None and book.best_ask is not None: + if book.best_bid > book.best_ask: + return None return book def touch(self, side: Side, market_id: str | None = None) -> Decimal | None: @@ -156,8 +162,7 @@ def last(self, market_id: str | None = None) -> Decimal | None: def mark(self, market_id: str | None = None) -> Decimal | None: instrument = market_id or self.parent.market_id - return (self.engine.fair_values.get(self.parent.account_key, instrument) - or self.mid(instrument) or self.last(instrument)) + return self.mid(instrument) or self.last(instrument) def levels(self, side: Side, market_id: str | None = None) -> list[tuple[Decimal, Decimal]]: """The far side of the book, best first: what a taker would eat.""" @@ -168,17 +173,50 @@ def levels(self, side: Side, market_id: str | None = None) -> list[tuple[Decimal rows = asks if side == Side.BUY else bids return [(level.price, level.size) for level in rows] + def defer_pending_action(self) -> bool: + if self.engine.dispatch_running and self.engine.dispatch_parent != self.parent.id: + self.engine.queue_managed_action(self.parent.id) + return True + return False + + def registered_precision(self) -> Precision | None: + specification = self.engine.market_master.find(self.parent.market_id) + return specification.precision if specification else None + + def last_trade_timestamp(self, market_id: str) -> int | None: + return self.engine.last_trade_timestamps.get(market_id) + + async def plan_child(self, request: OrderRequest) -> None: + await self.engine.journal.plan_managed_child(self.parent.snapshot(), request, + venue=self.parent.venue, account=self.parent.account, + parent_order=self.parent.as_order()) + + async def observe_pending_child(self, client_order_id: str) -> tuple[Order | None, Intent | None]: + order = await self.engine.journal.order_by_client_id(client_order_id) + intent = await self.engine.journal.intent(client_order_id) + if order is None and intent is not None and intent.state == IntentState.SENDING: + await self.engine._resolve(intent) + order = await self.engine.journal.order_by_client_id(client_order_id) + intent = await self.engine.journal.intent(client_order_id) + return order, intent + + async def settle_unsent_child(self, client_order_id: str) -> Intent: + await self.engine.journal.mark_intent(client_order_id, IntentState.SETTLED, + detail="parent ended before dispatch") + return await self.engine.journal.intent(client_order_id) + async def submit_child(self, request: OrderRequest, **kw: Any) -> Order: return await self.engine.submit_child(self.parent, request, **kw) async def cancel_child(self, order_id: str, venue: str | None = None) -> Order | None: + """Delegate to Engine.cancel_child(), supplying this context's managed parent.""" return await self.engine.cancel_child(self.parent, order_id, venue=venue) async def publish(self, kind: str, payload: dict[str, Any] | None = None) -> None: await self.engine.bus.publish(kind, {"parent_id": self.parent.id, **(payload or {})}, key=self.parent.id) async def save(self) -> None: - await self.engine.orders.save(self.parent) + await self.engine.managed_order_controller.save(self.parent) class ManagedOrder: @@ -192,22 +230,25 @@ def __init__( request: OrderRequest, *, id: str | None = None, - venue: str, - account: Account, + venue: str | None, + account: Account | None, + execution_accounts: dict[str, Account] | None = None, state: State = "waiting", ): self.id = id or f"mo-{uuid.uuid4().hex[:16]}" self.request = request self.venue = venue self.account = account + self.execution_accounts = execution_accounts or {} + self.legacy_execution_identity = None self.state: State = state self.children: list[Child] = [] self.created_ms = now_ms() self.updated_ms = self.created_ms self.detail: str = "" self.params: dict[str, Any] = dict(request.params or {}) + # Set when this parent is a leg of another one (a bracket's stop). self.owner_id: str | None = None - """Set when this parent is a leg of another one (a bracket's stop).""" # -- identity ------------------------------------------------------------- @@ -216,8 +257,8 @@ def market_id(self) -> str: return self.request.market_id @property - def account_key(self) -> str: - return self.account.key + def account_key(self) -> str | None: + return self.account.key if self.account else None @property def side(self) -> Side: @@ -259,7 +300,7 @@ def venues(self) -> set[str]: """Every venue this parent has or may put a child on. A single-venue parent is its own venue; a parent across venues overrides this so a halt scoped to any one of them reaches it.""" - return {self.venue} | {c.venue for c in self.children} + return ({self.venue} if self.venue else set()) | set(self.execution_accounts) | {c.venue for c in self.children} # -- hooks ---------------------------------------------------------------- @@ -275,8 +316,13 @@ async def on_trade(self, ctx: Context, market_id: str, price: Decimal, quantity: async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: """One of this parent's children filled, wholly or in part.""" - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: - """A child's status changed (cancelled, rejected, expired, closed).""" + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: + """React to a child order update after its local record has been updated. + + `order` is the venue result; `child` is this parent's tracked child. + Implementations decide whether that change means waiting, replanning + or finishing the parent. This callback does not fetch the venue order. + """ async def on_timer(self, ctx: Context) -> None: """About once a second, whatever else happened.""" @@ -289,16 +335,25 @@ async def on_halt(self, ctx: Context, reason: str) -> None: async def cancel(self, ctx: Context, reason: str = "cancelled", *, end: State = "canceled") -> None: """Pull every live child, then end as `end`: `canceled`, or `expired` - when it is the order's own expiry doing it.""" + when it is the order's own expiry doing it. A parent whose children + turn out to have filled everything ends `done` (`closed`) instead: + a cancel never relabels a fill.""" self.detail = reason if not self.live: return self.state = "cancelling" - await self.pull_children(ctx) - if not self.live_children: - await self.finish(ctx, end, reason) - else: + await ctx.save() + await self.cancel_live_children_without_waiting(ctx) + if not self.live: + # A child's final venue record, read while pulling it, ended the + # parent already (a fill that won the race closes it). + return + if self.live_children: await ctx.save() + elif self.remaining <= 0: + await self.finish(ctx, "done", "filled before cancellation completed") + else: + await self.finish(ctx, end, reason) async def expire(self, ctx: Context) -> None: """The order's own `expires_at` passed: the whole order ends @@ -307,20 +362,31 @@ async def expire(self, ctx: Context) -> None: is tried again.""" await self.cancel(ctx, "expired", end="expired") - async def pull_children(self, ctx: Context) -> None: - """Cancel every live child. + async def cancel_live_children_without_waiting(self, ctx: Context) -> None: + """Request cancellation of each live child once and record each response. - A cancel the venue accepted counts, whatever status it echoes back: - Kalshi's demo answers a cancel with the order still `resting`, and a - parent that believed that would wait for ever to stand down. A cancel - that fails is a different matter, and the child stays live so the - parent keeps saying it is still cancelling. + Await each request's response, but do not poll or wait for every child + to reach a final status. This is not a background task launcher. + A venue can accept the cancellation request while its response still + reports the order open. That is not a confirmed cancellation: record + the returned status and leave the child live. The caller must keep + processing updates or retry cancellation; this helper does not wait + for confirmation and does not itself schedule retries. """ for child in self.live_children: try: result = await ctx.cancel_child(child.order_id, child.venue) - child.status = result.status.value if result is not None and result.is_terminal else "canceled" + if result is not None: + # Copy what the venue actually reported. A fill may have won + # the race, or the cancellation may still be pending. + self.update_child_from_venue_order(result, child) + else: + # The existing cancel-child contract treats no returned + # order as canceled; this is distinct from an open response. + child.status = "canceled" except Exception as exc: + # Failure to obtain a result is not proof of cancellation. + # Preserve the last child status for subsequent processing. log.debug("synpath.engine.orders: cancelling %s failed: %s", child.order_id, exc) async def finish(self, ctx: Context, state: State, detail: str = "") -> None: @@ -330,17 +396,30 @@ async def finish(self, ctx: Context, state: State, detail: str = "") -> None: await ctx.save() await ctx.publish(f"managed.{state}", {"kind": self.kind, "filled": str(self.filled), "detail": self.detail}) - def note_order(self, order: Order, child: Child) -> Fill | None: - """Take the venue's latest record of a child. Returns the progress - since the last record as a `Fill`-shaped step for the `on_fill` - hook, or `None` if nothing more filled. Built from the order record - alone: the venue's fill events go to the ledger, never here.""" + def update_child_from_venue_order(self, order: Order, child: Child) -> Fill | None: + """Update the tracked child from an already-obtained venue order result. + + This method makes no venue request. Its caller supplies an order from + a submission/cancellation response, stream update or explicit status read. + Return newly filled quantity as a `Fill`-shaped step for `on_fill`, or + None if no additional quantity filled. Separate venue fill events go + to the ledger, not directly to this parent's progress. + """ + # Ignore a result that would move known fills backwards. + if order.filled < child.filled: + return None before, fee_before = child.filled, child.fee + # Replace cumulative totals and status; do not add the same fills again + # when a stream repeats a result already seen in a placement or cancel response. child.filled = order.filled or ZERO + if order.average_price is not None or child.filled > before: + child.average_price = order.average_price child.fee = order.fee or ZERO child.status = order.status.value self.updated_ms = now_ms() delta = child.filled - before + # No new fill does not mean no change: the child status above may + # have changed from open to canceled. Only the fill-progress return is None. if delta <= 0: return None return Fill( @@ -369,20 +448,24 @@ def child_request( time_in_force: TimeInForce | None = None, post_only: bool = False, expires_at: int | None = None, - reduce_only: bool | None = None, market_id: str | None = None, side: Side | None = None, params: dict[str, Any] | None = None, ) -> OrderRequest: - """A venue order in this parent's name, carrying its book and trader.""" + """A venue order in this parent's name, carrying its book and trader, + and doing what the parent does: a child on the parent's side has its + outcome and action (a Sell YES stop sells YES held, never buys NO); + one on the other side (a flipped bucket member) trades the other + outcome the same way.""" + child_side = side or self.side + outcome, action = self.request.outcome, self.request.action + if outcome is not None and child_side != self.side: + outcome = "no" if outcome == "yes" else "yes" return OrderRequest( - market_id=market_id or self.market_id, side=side or self.side, quantity=quantity, type=type, + market_id=market_id or self.market_id, side=child_side, outcome=outcome, action=action, + quantity=quantity, type=type, price=price, time_in_force=time_in_force or TimeInForce.GTC, post_only=post_only, expires_at=expires_at, - # A parent that may only reduce a position must not open one - # through its children: on Polymarket a reduce-only sell sells the - # YES held instead of buying NO. - reduce_only=self.request.reduce_only if reduce_only is None else reduce_only, - account=self.account, book=self.request.book, trader=self.request.trader, + user_id=self.request.user_id, account=self.account, book=self.request.book, trader=self.request.trader, tags={**self.request.tags, "parent": self.id}, params=params or {}, ) @@ -391,7 +474,7 @@ def track(self, order: Order, quantity: Decimal, price: Decimal | None) -> Child order that crossed on the way in comes back closed, and a parent that recorded it as resting would wait for a fill that has happened.""" child = Child(order_id=order.id, venue=order.venue, quantity=quantity, price=price, - filled=order.filled, status=order.status.value) + average_price=order.average_price, filled=order.filled, fee=order.fee or ZERO, status=order.status.value) self.children.append(child) self.updated_ms = now_ms() return child @@ -402,10 +485,13 @@ def snapshot(self) -> dict[str, Any]: """Everything needed to rebuild this parent after a restart.""" return { "id": self.id, "kind": self.kind, "state": self.state, "venue": self.venue, - "account": self.account.model_dump(mode="json"), "request": self.request.model_dump(mode="json"), + "account": self.account.model_dump(mode="json") if self.account else None, + "execution_accounts": {venue: account.model_dump(mode="json") for venue, account in self.execution_accounts.items()}, + "request": self.request.model_dump(mode="json"), "filled": str(self.filled), "children": [c.to_dict() for c in self.children], "created_ms": self.created_ms, "updated_ms": self.updated_ms, "detail": self.detail, "owner_id": self.owner_id, "extra": self.extra(), + "legacy_execution_identity": self.legacy_execution_identity, } def extra(self) -> dict[str, Any]: @@ -417,14 +503,18 @@ def load_extra(self, extra: dict[str, Any]) -> None: @classmethod def from_snapshot(cls, snapshot: Mapping[str, Any]) -> "ManagedOrder": - request = OrderRequest.model_validate(snapshot["request"]) + request = OrderRequest.stored(snapshot["request"]) parent = cls(request, id=snapshot["id"], venue=snapshot["venue"], - account=Account.model_validate(snapshot["account"]), state=snapshot["state"]) + account=Account.model_validate(snapshot["account"]) if snapshot.get("account") else None, + execution_accounts={venue: Account.model_validate(account) + for venue, account in (snapshot.get("execution_accounts") or {}).items()}, + state=snapshot["state"]) parent.children = [Child.of(row) for row in snapshot.get("children") or []] parent.created_ms = int(snapshot.get("created_ms") or now_ms()) parent.updated_ms = int(snapshot.get("updated_ms") or parent.created_ms) parent.detail = snapshot.get("detail") or "" parent.owner_id = snapshot.get("owner_id") + parent.legacy_execution_identity = snapshot.get("legacy_execution_identity") parent.load_extra(snapshot.get("extra") or {}) return parent @@ -433,15 +523,17 @@ def from_snapshot(cls, snapshot: Mapping[str, Any]) -> "ManagedOrder": def as_order(self) -> Order: """The parent as an `Order`, so callers see one order, not a scheme.""" average = None - spent = sum((c.filled * (c.price or ZERO) for c in self.children), ZERO) - if self.filled > 0 and spent > 0: - average = spent / self.filled + filled_children = [child for child in self.children if child.filled > 0] + if filled_children and all(child.average_price is not None for child in filled_children): + average = sum((child.filled * child.average_price for child in filled_children), ZERO) / self.filled return Order( - id=self.id, client_order_id=self.request.client_order_id, venue=self.venue, account=self.account, + user_id=self.request.user_id, id=self.id, client_order_id=self.request.client_order_id, venue=self.venue, account=self.account, + execution_accounts=dict(self.execution_accounts), market_id=self.market_id, side=self.side, type=self.order_type, time_in_force=self.request.time_in_force, status=STATUS_OF[self.state], held_by=HeldBy.ENGINE, price=self.request.price, stop_price=self.request.stop_price, quantity=self.quantity, filled=self.filled, remaining=self.remaining, average_price=average, book=self.request.book, trader=self.request.trader, + outcome=self.request.outcome, action=self.request.action, expires_at=self.request.expires_at, tags=dict(self.request.tags), created_at=self.created_ms, updated_at=self.updated_ms, info={"kind": self.kind, "state": self.state, "detail": self.detail, "children": [c.order_id for c in self.children], "params": self.params}, diff --git a/synpath/engine/orders/day.py b/synpath/engine/orders/day.py index 32f6735..9c58d19 100644 --- a/synpath/engine/orders/day.py +++ b/synpath/engine/orders/day.py @@ -11,8 +11,7 @@ holds the expiry, and the journal keeps the original `day` alongside the computed timestamp, so what the caller asked for is still visible. -The session is the operator's: `session_timezone` (the machine's zone by -default) and `session_end` (23:59:59). A desk in New York and one in London +The session is the operator's: `session_timezone` (`Europe/London` by default in the engine) and `session_end` (23:59:59). A desk in New York and one in London mean different things by "today", and the engine should not guess. """ from __future__ import annotations diff --git a/synpath/engine/orders/iceberg.py b/synpath/engine/orders/iceberg.py index 5e0cfc0..0fd55f6 100644 --- a/synpath/engine/orders/iceberg.py +++ b/synpath/engine/orders/iceberg.py @@ -89,7 +89,7 @@ async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: self.schedule_reload(ctx) await ctx.save() - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if order.is_terminal and self.state == "working" and self.remaining > 0: self.schedule_reload(ctx) await self.complete_if_done(ctx) diff --git a/synpath/engine/orders/lifecycle/README.md b/synpath/engine/orders/lifecycle/README.md new file mode 100644 index 0000000..10d161a --- /dev/null +++ b/synpath/engine/orders/lifecycle/README.md @@ -0,0 +1,17 @@ +# Order lifecycles + +These documents explain order behavior and distinguish current implementation from draft proposals. The four instruction types below have the SQLite and PostgreSQL lifecycle work; routed bucket orders still use the existing managed-order path. + +- [Market](market.md) +- [Limit](limit.md) +- [Stop market](stop-market.md) +- [Stop limit](stop-limit.md) +- [Shared managed states](managed-states.md): current internal states, public statuses and progress +- [Routed bucket order](routed.md): current transitions and lifecycle gaps +- [Routed lifecycle improvement plan](routed-lifecycle-plan.md): draft decisions and offline acceptance cases + +A stop's time in force is one for the whole order (armed, and after trigger +its child), as in IBKR and FIX: GTC, GTD or DAY; IOC and FOK are refused. + +[Shared storage and execution ownership](../../README.md) +owns identity, dispatch persistence, transaction boundaries, and recovery. diff --git a/synpath/engine/orders/lifecycle/limit.md b/synpath/engine/orders/lifecycle/limit.md new file mode 100644 index 0000000..bffa295 --- /dev/null +++ b/synpath/engine/orders/lifecycle/limit.md @@ -0,0 +1,30 @@ +# Limit order + +Required fields are market ID, side, quantity and `price`. Supported time +in force and expiry follow the adapter; GTD requires an absolute expiry. + +Risk validation precedes persistence and dispatch. The venue can rest the +order and owns its fills and eventual cancellation or expiry. A later venue +observation updates progress without changing the submitted limit instruction. +An uncertain send is reconciled before retrying. A normal worker restart +preserves the order; it does not imply cancel-and-replace. + +The `limit_orders` table stores the instruction and latest execution state. +For example, a sell for 10 at 0.38 can fill at 0.39; retain the original 0.38 +instruction alongside the reported execution result. + +See [shared storage and ownership](../../README.md). + +A same-key retry returns the original result. Reusing the key for another +instruction is a conflict. An ambiguous earlier send is reconciled; it is +never automatically resent. A send with no verdict raises `SubmissionInDoubt` +(`202 submission_in_doubt` over HTTP); a retry before the venue answers raises +`SubmissionPending` (`202 submission_pending`). + +Cancelling an order already `closed`, `canceled`, `rejected` or `expired` +returns it unchanged without a venue call. If the venue answers a cancel with +"not found", the engine reads the order back and records the venue's verdict +with its filled quantity: a limit that filled before the cancel arrived ends +`closed`, never `canceled`. A venue with no record of the order confirms +nothing (`CancelUnconfirmed`, `409 cancel_unconfirmed`); the last observed +status stands. diff --git a/synpath/engine/orders/lifecycle/managed-states.md b/synpath/engine/orders/lifecycle/managed-states.md new file mode 100644 index 0000000..90b1171 --- /dev/null +++ b/synpath/engine/orders/lifecycle/managed-states.md @@ -0,0 +1,57 @@ +# Managed order states in the current code + +Status: current implementation walkthrough, updated on 22 October 2026 for the readability checkpoint and routed cancellation fix. Proposed changes belong in [the routed lifecycle plan](routed-lifecycle-plan.md). + +A managed order is an instruction the Engine continues running, such as a stop, iceberg or routed bucket order. Its state describes its execution phase. Its filled quantity is a separate measurement. This distinction lets an order be both canceled and partially filled. + +## Internal state and public status + +[`base.py`](../base.py) defines `State`, `LIVE_STATES` and `STATUS_OF`. `ManagedOrder.as_order()` translates internal state into the public `Order.status` using that shared mapping. + +| Internal state | Public status today | Meaning | +|---|---|---| +| `waiting` | `waiting` | Initial state; for a stop, its condition has not fired. For a bucket, this is only the initial state before `start()`. | +| `working` | `triggered` | The implementation has started its active behavior. For a routed order, read `triggered` as active managed execution, not a price trigger; the existing mapping is being retained. | +| `cancelling` | `pending_cancel` | The parent is trying to stop its children; it is not yet finished. | +| `done` | `closed` | The implementation considers its instruction complete. Routed orders use this for full fills. Other order types need their own completion rules. | +| `canceled` | `canceled` | The parent has ended without completing its requested quantity; fills may already exist. | +| `rejected` | `rejected` | An error path ended the managed order. This is distinct from rejecting a request before a parent is created. | +| `expired` | `expired` | The parent ended through the shared explicit-expiry path. Type-specific timers do not necessarily use this state. | + +Only `waiting`, `working` and `cancelling` are considered live. Final states are excluded from the controller's periodic processing. Therefore choosing a final state too early can prevent subsequent cleanup. + +The seven names form a shared vocabulary, not a complete transition specification for every type. The implementation classes decide which transitions occur. + +## Quantity is independent of state + +`ManagedOrder.filled` sums the latest recorded filled quantity of each child. `remaining` is `max(0, requested - filled)`. The controller's `on_order()` records a child's latest venue result, derives any new fill progress, and calls the parent's `on_fill()` and `on_child_order_updated()` methods. Separate venue fill notifications go to the ledger, not directly to parent progress. + +For an instruction to buy 100: + +| State | Filled | Remaining | Interpretation | +|---|---:|---:|---| +| `working` | 30 | 70 | Still trying to fill the remaining 70. | +| `cancelling` | 30 | 70 | Trying to stop; some of the remaining 70 may still fill. | +| `canceled` | 30 | 70 | Stopped with 30 filled and 70 unfilled. | +| `done` | 100 | 0 | Fully filled, for this routed order. | + +`remaining` is unfilled requested quantity, not necessarily quantity still resting at venues. A final canceled order can retain a positive `remaining`. + +## Who changes the state + +1. `ManagedOrderController.create()` constructs the implementation, saves and registers it, then calls its `start()` through `run()`. +2. The controller forwards book updates, child order updates and timer ticks to that implementation. +3. Type-specific methods decide whether to continue, stop or finish. `finish()` sets state and detail, saves, then publishes an event. +4. The shared `cancel()` sets `cancelling` before pulling children. It stays pending while children remain live; otherwise it ends `done` if filled or uses the requested final state. +5. Shared `expire()` calls `cancel(..., end="expired")`. The controller checks explicit `expires_at` on timer ticks. Routed orders override `cancel()` to retain the intended ending, process delayed child results and retry cancellation without routing; see the routed lifecycle. +6. `_run_hook()` handles errors and saves the resulting parent. Network errors and timeouts retain the current state with a detail message. Other errors can end `rejected`, subject to the implementation's recorded uncertain-child information. + +A call to `finish()` does not itself verify that every child is terminal. Each caller is responsible for satisfying that condition. Read type-specific paths before assuming the shared helpers enforce it. + +## Code reading order + +- [`base.py`](../base.py): `State`, `STATUS_OF`, `filled`, `cancel()`, `expire()`, `finish()`, `complete_if_done()`, `as_order()`. +- [`manager.py`](../manager.py): `create()`, `_run_hook()`, `on_order()`, `on_timer()`. +- [Routed order lifecycle](routed.md): the bucket-specific path and its current gaps. + +Storage transactions, restart guarantees and uncertain-send recovery are the next review phase. This walkthrough does not establish those guarantees; see the [engine README](../../README.md) for the existing shared design. diff --git a/synpath/engine/orders/lifecycle/market.md b/synpath/engine/orders/lifecycle/market.md new file mode 100644 index 0000000..3e6e7ee --- /dev/null +++ b/synpath/engine/orders/lifecycle/market.md @@ -0,0 +1,47 @@ +# Market order + +Required fields are market ID, side and quantity. Venues requiring a worst +price also require `price`. The worker assigns `user_id` and the client key. + +Risk validation precedes the durable instruction and dispatch intent, then +the venue receives the order. The venue owns acceptance and fills; local +observations record pending/open/closed/canceled/rejected/expired results. +IOC may finish with an unfilled remainder. Cancellation acts on an accepted +venue order; uncertain sends must be reconciled by the existing client key. + +The `market_orders` table preserves the original instruction separately from +the latest venue result. For example, buying 10 contracts at the best available +price with an internal worst price of 0.99 persists before the adapter call. + +See [shared storage and ownership](../../README.md). + +A same-key retry returns the original result. Reusing the key for another +instruction is a conflict. An ambiguous earlier send is reconciled; it is +never automatically resent. + +## On Kalshi + +Kalshi has no market order type. A `market` request is sent as one +immediate-or-cancel limit at `price` (`fill_or_kill` with `time_in_force=fok`), +so it takes what the book offers up to that price and never rests: it ends +`closed` when anything filled (the unfilled remainder recorded) and +`canceled` when nothing did. A request without `price` is refused before +anything is sent. A caller that wants no worst price of its own passes the +legal bound for its side, as a stop market's child does: 0.99 for a buy (a +YES bid), 0.01 for a sell (a YES ask); the terminal does exactly that. +Risk values the order at its worst price, so `max_order_notional` counts a +market buy of 10 at 0.99 as 9.90 whatever it fills at. + +Risk refusals (size, notional, kill switch) are raised before the instruction +is written; nothing reaches the venue. + +## Tests + +`tests/test_serve_market_orders.py`, through a whole `synpath serve` on the +mocked Kalshi, on SQLite and PostgreSQL: the instruction row in +`market_orders` before the send, one IOC at the worst price, the order +`closed`; a Sell YES at 0.01 keeping its outcome and action (`reduce_only` refused); a same-key retry sends once; a lost +response is `submission_in_doubt` and the retry adopts it without resending; +another instruction under the same key is refused; no worst price is refused +with nothing sent. A stop market's child is also a `market` order on this path +(`stop-market.md`). diff --git a/synpath/engine/orders/lifecycle/nominal-venue-removal-design.md b/synpath/engine/orders/lifecycle/nominal-venue-removal-design.md new file mode 100644 index 0000000..36f58e6 --- /dev/null +++ b/synpath/engine/orders/lifecycle/nominal-venue-removal-design.md @@ -0,0 +1,281 @@ +# PRE-6: cross-venue parent identity and execution scope + +Status: approved for implementation on 25 October 2026, including D6/D7 independent-review additions. Offline verification and pre-landing review are required before merge. No actual trades are authorized. + +Issue: https://linear.app/synpath/issue/PRE-6/remove-arbitrary-nominal-venue-from-cross-venue-smart-order-parents + +## Baselines and destinations + +- prediction_market worktree: `/Users/slingshot/.codex/worktrees/pre-6-cross-venue-parent/prediction_market`, based on main `cda4369a804a96f54288966ab96cf2438ef2de10`; eventual PR base `main`. +- Public SDK worktree: `/private/tmp/pre-6-synpath-public`, branch `codex/pre-6-cross-venue-parent`, based on experimental branch `85013870850c5741788a99a0c746fe224de2e366`; eventual PR base `codex/order-lifecycle-storage`. +- The original checkouts contain unrelated work and remain untouched. +- The current terminal is a read-only market-data application with execution previews. It has no order submission endpoint. Execution UI on another unmerged branch is not assumed to be part of main. +- The latest experimental SDK branch is the terminal compatibility policy. Record tested commits in evidence; do not introduce a workspace SHA pin or update production package versions. + +## Shareable explanation: old uses and replacements + +1. **Account selection.** The parent took the first enabled member's venue and its account, and child requests initially inherited that account; children on other venues then replaced it. The parent will instead belong to the configured user, with a saved map of eligible venues to execution accounts. Each child selects its account directly from that map using its own market ID, independent of member order. +2. **Risk checks.** The parent currently runs a single-venue risk check against the nominal venue/account, although its children can execute elsewhere. The replacement separates checks on the whole instruction from checks on all eligible execution scopes, followed by the existing checks on each actual child. Flipped members use their own side and complemented YES price for member-specific checks. +3. **Permissions.** Bucket submission already checks member-venue permissions, but parent reads, amendments and cancellations use its one nominal account. The replacement checks the saved account scope consistently: viewing the whole parent requires visibility of every account; actions affecting the whole parent require trade permission on every account. Later bucket edits cannot change those permissions for an existing parent. +4. **Storage and lookup.** The nominal venue currently supplies the parent projection's database key and participates in account filters, pagination and cached lookup. The replacement uses the stable parent ID and a private managed-order storage namespace, with an explicit saved execution scope for account and venue membership queries. Existing parent rows and snapshots require a reviewed migration; venue children keep their existing keys and attribution. +5. **Reporting and lifecycle.** Public parent responses currently show the nominal venue/account even when all fills occur elsewhere. The replacement reports no single execution venue/account, lists eligible venues/accounts explicitly, and continues to derive actual execution and fills from children. Cancellation targets each child's real venue, while a halt of any eligible venue reaches the whole parent, as the routed parent's existing `venues()` method already intends. + +## Proposed contract and ownership + +Use the existing user identity; `owner_id` on ManagedOrder already means a nested managed-order relationship and must not be repurposed as user ownership. Keep book and trader attribution unchanged. + +A routed parent, including a bucket with only one enabled member, has `venue=null`, `account=null`, and an explicit execution scope containing a deterministically ordered venue-to-Account map. This scope is captured with the enabled bucket definition before routing begins and saved in the durable snapshot. Existing single-venue parents and venue orders retain their concrete venue/account. Validate that a venue-held order always has a concrete venue; nullable parent fields must never reach adapters, fills, or ledger keys. + +Reject a caller-supplied singular account for a new bucket instruction with an actionable error: it cannot specify the accounts of several venues. Initially use the engine's configured per-venue accounts, retaining the established default-account convention where applicable. A new API for selecting several accounts is outside this ticket. Validate each selected account's venue against its member venue and the configured execution adapter; do not claim arbitrary subaccount routing when an adapter only supports one configured identity. + +Persist the exact account selection; a restart with conflicting execution configuration must block new dispatch for that parent with an actionable diagnostic rather than silently select a different account. Preserve access to historical records and the ability to reconcile or clean up through the original accounts when those adapters are available. + +D4 accepted on 25 October 2026: prepare the enabled bucket and its account map once in the trusted submission flow. HTTP permission checks, parent risk, durable persistence and routing consume that same prepared snapshot; the engine must not reread the mutable bucket after authorization. Preparation is internal, never trusted from a caller-supplied account-scope payload. Direct SDK calls reuse the preparation path while retaining the existing execution-owner checks. Bind any supported same-client-ID retry to the saved original instruction/scope, not a newly edited bucket. Test a bucket edit interleaved between authorization and dispatch. + +```text +user + bucket instruction + client order ID + | + enabled bucket snapshot + saved venue -> account map + | + instruction / scope validation + authorization + parent risk + | + durable routed parent + | + existing fee-aware allocator + / \ + member market A flipped member B + venue/account A venue/account B + child risk/send converted child risk/send + \ / + child status / fills + | + aggregate parent and report +``` + +## Risk policy agreed in architecture review + +D2 accepted on 25 October 2026: conservative admission requires capacity for the full requested quantity at every eligible destination. Keep the existing risk rechecks on actual children; admission does not reserve capacity or guarantee execution. + +Run instruction-wide rules once: user/book validity, price and quantity bounds, order notional, applicable duplicate/rate controls and daily loss. Check the bucket restriction itself and every enabled member restriction. Check venue halts across all enabled venues before accepting the instruction, even when today's best price would allocate only to one venue. + +For member/account risk, project the full remaining quantity to each eligible member as a conservative admission check: any eligible member may receive that quantity after a price change. Convert flipped side, outcome and price into member coordinates, and use that member's account, market-close time, event, positions and open orders. Alternative destinations are alternatives, not simultaneous allocations: do not add a full parent quantity to every venue's actual exposure or count parent and children as the same resting venue order. Aggregate venue-level bounds once per venue/account rather than once per member. + +This policy can reject a parent whose current split would fit limits but whose future full allocation to one member would not. That is an explicit tradeoff for review. An allocation-only admission policy would be less restrictive but would allow accepted parents to stall on later child risk checks. Actual child dispatch always rechecks current risk; admission is neither a funds reservation nor a guarantee of later acceptance. Existing ineffective or incomplete risk rules, such as a venue-notional rule without supplied exposure, must be identified in verification rather than described as enforced. + +## Persistence, query and compatibility policy + +Retain managed_orders as the authoritative routed-parent snapshot and retain the shared order projection for now. PRE-5's removal of parent copies is a separate issue, not a dependency to implement here. + +The shared projection's current `(venue,id)` primary key requires non-null values. Use an explicitly private reserved managed storage namespace for routed parent rows, while keeping their serialized public execution venue null. Centralize conversion between storage identity and execution identity so no caller accidentally treats the storage namespace as a venue. Never configure an adapter/account for that namespace. Parent lookup, including finished parents after restart, must use managed ID lookup rather than scanning only configured adapters. + +Persist a normalized parent execution-account membership relation, keyed by parent ID and account key with its concrete venue, alongside each new parent snapshot. Use it for indexed venue/account filtering and scope lookup without scanning and decoding every historical snapshot or fetching current mutable bucket definitions. A parent matches any eligible venue filter and appears once in an unfiltered list. Preserve stable pagination using the private storage key and parent ID; reject incompatible pre-upgrade cursors clearly and require clients to restart pagination. + +D3 accepted on 25 October 2026: save the parent snapshot, execution-account membership, shared readable order projection, submission-result bookkeeping and related journal events in one transaction. Refactor journal writes into transaction-owned operations and helpers that do not start or commit transactions themselves. Update in-memory caches only after a successful commit; propagate cancellation/failure without publishing uncommitted state. Reuse this transaction boundary for parent transitions coupled to child intents. Verify rollback and restart with failures injected between writes on both databases. + +Add an immutable Alembic revision for the relation and routed-parent projection rekey/backfill. Migrate routed managed snapshots, shared projections and account indexes consistently; preserve parent IDs, client IDs, user identity, statuses, fills, child IDs and actual child venue/account attribution. Preserve old nominal values as legacy provenance, not operative ownership. Do not edit old audit events to pretend they originally contained the new contract. + +Infer historical accounts only from unambiguous persisted child/intent evidence and the historical parent account for its matching venue. Never infer a previously unknown execution account from the current configuration. If a legacy live parent lacks sufficient evidence for an enabled venue, require an explicit reviewed mapping before enabling dispatch; do not silently broaden access or move execution. Test and document this case as an upgrade preflight requirement. The migration and startup validation must fail transactionally on conflicting evidence. + +Update HTTP response schemas and any experimental Python callers that assume a string parent venue. Scope predicates must be shared by list/detail/amend/cancel and parent-bearing event delivery/replay. The current event socket sends subscribed events without account-level filtering; parent scope filtering must cover both replay and live delivery, and this ticket must not claim that unrelated event types have thereby received a complete security audit. + +Require all-account visibility for a complete parent/report and all-account trade permission for a parent action. A caller may still see an authorized individual child through existing child routes without seeing the whole aggregate. Do not expose another venue's fills through a partial-account parent report. + +Upgrade both SQLite and PostgreSQL through the existing application-controlled migration path and one execution worker. Back up and verify restore first. Old binaries must not open the upgraded journal; downgrade requires a compatible backup and resolved outstanding venue activity. SDK merge precedes workspace merge, and workspace CI must verify the actual resulting experimental branch tip. + +## Verification plan + +Verification uses existing PaperVenue and HTTP test fixtures without exchange credentials. Actual results are recorded separately from this design in the linked workspace verification report. + +```text +Engine.submit(bucket request) / POST /orders on the standalone trading app + -> saved scope -> risk -> durable parent + -> existing router -> two scripted venue adapters + -> child responses/fills -> parent/report + -> list/detail/filter/cancel/halt + -> process restart + migrated database -> same identities/scope + +Terminal fixture book -> select bucket -> enter size/worst price + -> routed preview -> correct per-venue legs + -> contract checks + frontend build (no order submission UI) +``` + +1. **Ordering invariance:** run equivalent buckets with member order reversed and unambiguous best prices; assert identical owner, saved account map, risk/permission decisions and economic allocations, ignoring generated IDs and timestamps. Include all allocation on the non-first venue, multi-venue fills, disabled members and flipped YES/NO members. Assert actual requests delivered to each adapter carry that venue's exact named account. +2. **Risk and authorization:** deny a non-first venue/account, halt it, restrict its market, exceed member/account position limits, and exercise mixed-view/trade grants. Verify denial before dispatch where admission requires it, exact child-side checks after flipping, authorized child access, and no parent/report/live-event/replay leakage to a partial-account key. Check the proposed conservative admission tradeoff explicitly. +3. **Lifecycle and storage:** use resting children, partial fills, colliding venue-native order IDs, delayed cancellation confirmations and cancellation/fill races. Cancel or halt through the public entry points and assert every child is addressed on its own venue; parent stays pending until tracked children resolve. Restart working, cancelling and finished parents; verify ID lookup, reports, filters, pagination, saved account scope and unchanged aggregate fills. A changed bucket definition or account configuration must not silently alter the instruction. +4. **Upgrade:** prepare pre-change databases with live and finished routed parents, child evidence, incomplete/conflicting account evidence and ordinary single-venue orders. Apply the new migration on SQLite and PostgreSQL; verify identity/count/state preservation, no duplicate projection after rekey, explicit failure for unsafe mappings, and successful restart/cancel of migrated parents. Repeat normal startup to verify idempotence. Preserve known recovery limitations rather than claim a new all-children reconciliation barrier. +5. **Terminal compatibility:** extend the installed-consumer smoke check with a minimal offline bucket submission and scope/child-account assertions. Run terminal backend tests, generated-contract consistency and frontend build against the exact candidate SDK commit. In a browser with deterministic market fixtures, select a two-venue bucket, enter size and worst price, inspect routed preview legs, reverse member order and compare. Confirm Submit remains disabled as specified on main. +6. **Release evidence:** require SDK native and pure-Python tests, Rust checks and PostgreSQL storage CI, plus workspace terminal CI against the merged experimental branch tip. Record both tested commit hashes and sanitized results. Create linked PRs, run `/review`, resolve findings, merge SDK to `codex/order-lifecycle-storage` first, then reverify and merge prediction_market to `main`. + +## Implementation scope proposed for engineering review + +Expected SDK areas: trading models; engine bucket admission/child dispatch/risk integration; managed base/controller/routed scope; journal and projection lookup/filtering; membership schema and Alembic migration; server authorization/events; lifecycle documentation; routing/API/recovery/migration/storage tests. Workspace areas: installed-consumer smoke test, terminal integration decision/rollout documentation and any necessary CI compatibility wiring. This necessarily exceeds eight files but introduces no new service or execution worker. + +Suggested sequence: define the scope/model and migration contract; implement shared scope/identity helpers and storage; update admission/dispatch and risk; update server authorization/query/event paths; add regressions and consumer verification; run review and land in dependency order. Both worktrees remain isolated throughout. Cross-repository tasks are sequential where the consumer depends on the SDK contract. + +## Required verification report + +User requirement confirmed on 25 October 2026: deliver a verification report with screenshots and code snippets showing inputs and actual captured outputs. Do not make actual trades. Use offline scripted/PaperVenue adapters with no real exchange execution credentials; browser verification uses deterministic market fixtures. + +The final report must include: + +- Exact workspace and SDK commits tested, database backend and migration revision, commands or runnable snippets, test counts/results, and CI links where available. +- Minimal SDK and HTTP request snippets, paired with sanitized captured responses demonstrating a venue-neutral parent, its saved execution accounts, and the actual venue/account on each child. Clearly label all examples as simulated execution. +- Before/after or paired runs for reversed bucket members, all allocation to the non-first venue, and a flipped member, with expected versus observed outcomes. +- Evidence for permission/risk denials, venue-scoped halt and cancellation, restart/finished-parent lookup, and legacy migration. Show relevant persisted state or query results without exposing credentials or private account data. +- Screenshots of the terminal's deterministic two-venue bucket preview, including the entered quantity/worst price and resulting venue legs, plus readable captures of SDK/API verification results where useful. Label screenshots by scenario and distinguish preview evidence from simulated execution evidence; the current terminal cannot submit orders. +- A requirement-to-evidence table linking ticket acceptance criteria to tests, screenshots and captured outputs, with explicit pass/fail/not-run status and remaining limitations. Never substitute illustrative output or a mockup for observed results. + +Keep the report and its screenshot assets available as reviewable artifacts and link them from both PRs. Finish the report before requesting the final merge decision or merging under the user's conditional authorization. No live execution is part of this report. + +## Existing mechanisms to reuse and exclusions + +Reuse user identity, captured routable bucket, market-ID venue resolution, fee-aware allocation, per-child risk, parent IDs, managed snapshots, per-child venue cancellation, RoutedLimit.venues(), scripted venue fixtures and existing CI. Do not build a new router, order state machine, account service or terminal trading screen. + +Outside scope: public SDK main/release/PyPI, production dependency changes, live trading, PRE-5 projection removal, multi-user/multi-worker execution, a multi-account selection UI, routing tie-break redesign, and the broader uncertain-send/resync redesign documented in routed.md. No unrelated TODO cleanup. + +## Approved decisions + +D1 retains two linked repository PRs. D2 chooses full-capacity admission at every +eligible destination. D3 requires atomic parent/account/projection persistence. +D4 captures one scope for authorization, risk and execution. D5 requested an +independent review; D6 includes HTTP audit-route permissions and D7 includes +account checks before recovery/cleanup I/O. D8 explicitly approved implementation +with those additions. The user requested all remaining material questions in one +batch and prohibited actual trades. The verification report must include actual +screenshots, runnable input snippets, captured outputs and exact tested commits. + +## Engineering review: architecture + +Scope accepted in D1. Three findings were resolved with explicit user decisions: (1) D2 chooses conservative full-quantity admission at every eligible destination; (2) D3 makes parent state, scope, projection and related bookkeeping atomic; (3) D4 authorizes and executes the same captured snapshot. No new service, worker, dependency or published artifact is introduced. Existing SDK and terminal build/CI pipelines remain the distribution and compatibility checks. + +Migration is deliberately fail-closed for ambiguous historical account evidence. Preflight must identify such cases before upgrade while the old environment is still available; resolve mappings explicitly rather than starting new execution under guessed accounts. Account mismatches after restart block new dispatch rather than rewrite the saved scope. Scoped event delivery checks both replay and live delivery. These policies are already included above and require no additional architecture decision. + +## Engineering review: code quality + +No additional issues found against the revised plan. Reuse one scope preparation/validation implementation, one authorization predicate for complete parents, one private storage-identity conversion, and transaction-free low-level write helpers called by transaction-owning journal methods. The storage protocol must expose the atomic parent-save operation consistently for both backends; PostgreSQL already inherits the shared journal operations. + +Do not spread `venue is None` fallbacks across adapters or mint a synthetic Account. Model validation distinguishes a cross-venue engine parent from a venue-held order. Keep existing single-venue behavior on its existing path. Storage and lifecycle diagrams must describe the actual transaction boundary and distinguish attempted cancellation/resync from confirmed venue outcomes. Prior cancellation/restart fixes in commits `e2df3ba` and merge `27b4ea4` identify an area for regression testing; their state mappings and delayed-confirmation behavior are retained. + +## Engineering review: tests and failure modes + +Frameworks: SDK uses pytest/anyio with PaperVenue and FastAPI TestClient; SQLite/PostgreSQL contract tests already share scenarios. Terminal uses unittest backend tests plus generated-contract checks and TypeScript/Vite build; browser checks cover observable preview behavior. Existing routed tests cover fee-driven non-first allocation, flips, cross-venue cancellation, scoped halts and delayed-cancellation recovery. Extend these fixtures rather than invent another simulation engine. + +The original verification plan plus the accepted D3/D4 regression requirements covers the planned paths. No additional test-policy decision is needed. The following map specifies branches and expected observable outcomes; implementation must add assertions to the named suites and record actual results in the report. + +```text +submit: trusted prepare scope [test_routed_limit / test_serve_buckets] + |-- missing/inactive/empty bucket, missing adapter -> explicit refusal, no send + |-- singular account or mismatched account/venue -> explicit refusal, no send + |-- valid -> capture enabled members/accounts exactly once + |-- concurrent bucket edit -> captured scope remains operative [D4] + |-- unauthorized account -> 403, no parent dispatch + |-- global/member halt or member risk denial -> rejection, no dispatch + |-- all checks pass -> atomic save [test_order_storage] + |-- failure/cancellation between any writes -> full rollback [D3] + |-- commit -> update memory and start existing router + |-- normal/flipped/split/non-first allocation -> exact child accounts + |-- child risk changes -> existing child rejection behavior + |-- fills/status repeats -> unchanged cumulative attribution + +read/action: parent ID [test_server_trading / test_serve_buckets] + |-- partial grants -> hide/refuse complete parent and report + |-- all grants -> list/detail/report/cancel/amend as supported + |-- venue/account filter -> scope membership, one parent row, stable paging + |-- live/replayed parent event -> same visibility rule + |-- unknown/malformed identity or cursor -> explicit error, no fallback account + +restart/upgrade [test_routed_recovery / test_owner_migrations / test_postgres_storage] + |-- fresh DB -> new schema; repeated start -> unchanged state + |-- legacy, unambiguous -> preserve IDs/children/fills, rekey parent once + |-- missing/conflicting historical account -> explicit preflight/migration failure + |-- failure/cancellation during migration -> unchanged old data, reopen safely + |-- unfinished parent -> restore scope, reconcile/cancel original children + |-- finished parent -> ID lookup/report still works without live controller entry + |-- changed account config -> block new dispatch, retain original attribution + |-- same client-ID retry where supported -> original parent/scope, no duplicate send + +terminal [backend unittest / consumer smoke / browser] + fixture books -> select bucket -> enter size/worst price -> preview venue legs + |-- reversed members / flipped outcome -> expected equivalent economics + |-- missing liquidity -> visible remainder + |-- Submit -> remains disabled (preview only) +``` + +| Failure mode | Planned error handling and user-visible result | Required evidence | +|---|---|---| +| Bucket changes during permission check | Continue using the authorized captured snapshot | Interleaved edit test, assert adapters receive only captured members | +| Parent save fails after membership/projection write | Roll back the whole transaction; no accepted parent/event/cache update | Inject failure at each boundary on SQLite and PostgreSQL, reopen and compare all rows | +| Historical account evidence is missing or conflicts | Refuse unsafe migration/dispatch with affected parent diagnostic | Legacy fixtures and rollback assertions on both databases | +| Account configuration changes after restart | Refuse new sends through a different account | Restart fixture, zero new sends, unchanged saved identity | +| Partial-account caller requests parent or replays events | Hide aggregate or return permission error; no unauthorized account data | HTTP/report/live-event/replay grant matrix | +| Two venues return the same native order ID | Match children by concrete venue plus native ID | Two-venue cancellation/fill fixture | +| Cancel acknowledgement remains open or races with fill | Retain pending cleanup and cumulative fills; never claim unconfirmed cancellation | Existing cancellation scenarios extended to null-venue parents and restart | +| Parent is finished before restart | Retrieve by managed ID without scanning adapters | Finished-parent detail, cancel/idempotent read and report tests | +| Many parents or repeated scope lookups | Indexed membership filters and batched scope retrieval | Query-count and query-plan assertions; no full-history snapshot scan | + +No unaddressed silent critical failure mode was identified in this plan review. The broader uncertain-send/resync limitations remain explicitly outside this change; this is not a claim of production trading readiness or a test result. + +## Engineering review: performance + +No additional issues found against the planned normalized membership relation. Index concrete venue/account membership for filtered queries, use `EXISTS` or deduplicated joins so a parent is returned once, and load page scopes in a batch rather than one lookup per row. The snapshot remains authoritative; membership is its transactional query projection, never an independent mutable account-selection source. + +Parent admission scales with enabled members, not historical parent count. Reuse one view of the engine's current open orders per admission and group by venue/account before per-member checks; actual children retain their existing checks. Keep order/fee planning unchanged. Test query counts with several page sizes and inspect SQLite and PostgreSQL query plans for the membership filters. Do not add speculative caching or a benchmark service. + +## Implementation Tasks + +- [x] **T1 (P2)** — SDK scope/admission — implement the venue-neutral routed-parent contract and the D2 full-capacity risk policy; reuse concrete-account child dispatch. Verify reversed members, all allocation to non-first venue, flips, named accounts and conservative rejection of an otherwise valid split. +- [x] **T2 (P1)** — SDK journal/migrations — implement D3 atomic parent persistence and scope membership, private projection identity and legacy backfill. Verify injected failures, cancellation, restart and migration on both backends. +- [x] **T3 (P1)** — SDK submission/server — implement D4 single captured scope and consistent scope-based read/action/event authorization. Verify the interleaved bucket edit and the all-account/partial-account permission matrix. + +These tasks derive from the three findings above; ordinary implementation steps and the required verification report remain described in their own sections. Human/agent effort estimates are not supplied because this repository has no applicable compression table; measure actual work rather than invent precision. No new tasks arose from code quality, test-policy or performance review. + +## Dependencies and worktrees + +| Step | Modules | Depends on | +|---|---|---| +| Contract and captured scope | SDK trading and engine | — | +| Atomic storage and migration | SDK engine storage and migrations | Contract | +| Submission, risk, authorization and recovery | SDK engine and server | Contract, storage | +| SDK regression verification | SDK tests | Submission/storage paths | +| Consumer check and terminal evidence | Workspace research, terminal and CI | Candidate SDK | +| Review and landing | Both PRs | All checks and report | + +Sequential implementation in two isolated repository worktrees: the consumer depends on the SDK contract and no parallel agent work is required. SDK regression and terminal checks may run independently once the candidate SDK is stable. Merge SDK into the experimental branch, then rerun workspace checks against that resulting branch commit before merging workspace main. + +## NOT in scope + +- Public SDK main, release/PyPI and production pins: the intended dependency remains the experimental branch. +- Actual trades: the user explicitly prohibited them; all execution evidence is simulated. +- Terminal trading UI: main only previews orders, and enabling execution is a separate product change. +- PRE-5 projection removal: retaining the projection avoids coupling two storage redesigns. +- Multi-user, multi-worker and account-selection UI: reuse current ownership and configured adapters. +- Routing ties and broader uncertain-send/resync redesign: preserve the existing behavior and documented limitations. + +## What already exists + +Reuse the user owner registry, bucket routable snapshot, fee-aware router, venue-specific child dispatch, child-based cancellation, `RoutedLimit.venues()`, managed snapshot recovery, Alembic upgrades and shared SQLite/PostgreSQL test scenarios. Reuse terminal CI's SDK installation, backend tests, consumer smoke check, schema check and frontend build. No existing TODO file was found; no new deferred TODO is proposed. Existing lifecycle follow-ups remain documented in routed.md and routed-lifecycle-plan.md. + +## Independent review resolutions + +The fresh-context independent review found two gaps, both accepted by the user: +HTTP `/journal` and `/order-log` now fall under the saved parent-scope access +contract, and account identity checks must precede early recovery reads and +cleanup writes as well as new dispatch. Legacy events resolve durable scope; +unknown parent attribution is withheld from partial-account viewers. A changed +account must remain unresolved rather than be marked lost/final by reading a +replacement account. These additions are part of the approved scope. + +## GSTACK REVIEW REPORT + +| Review | Trigger | Why | Runs | Status | Findings | +|---|---|---|---|---|---| +| CEO Review | — | Scope strategy | 0 | Not needed | Contract correction | +| Independent plan review | Outside voice | Fresh-context challenge | 1 | Resolved | 2 P1 findings accepted in D6/D7 | +| Eng Review | `/plan-eng-review` | Architecture, code quality, tests, performance | 1 | Clear | 5 findings resolved through explicit decisions | +| Design Review | — | Visual experience | 0 | Not needed | No UI redesign | +| DX Review | — | Developer experience | 0 | Not needed | Existing setup/CI | + +- **UNRESOLVED:** 0 plan decisions. +- **VERDICT:** Engineering plan cleared and implementation approved. Code review, offline verification report and satisfactory CI still gate landing. No actual trades are authorized. diff --git a/synpath/engine/orders/lifecycle/nominal-venue-verification-plan.md b/synpath/engine/orders/lifecycle/nominal-venue-verification-plan.md new file mode 100644 index 0000000..c84a7f0 --- /dev/null +++ b/synpath/engine/orders/lifecycle/nominal-venue-verification-plan.md @@ -0,0 +1,63 @@ +# PRE-6 verification plan + +Prepared during `/plan-eng-review`, 25 October 2026. Status: approved and executed offline; final CI and merged-tip evidence gate landing. Full design: [nominal-venue-removal-design.md](nominal-venue-removal-design.md). Implementation was explicitly approved with audit-route and recovery safeguards. All execution is offline through scripted venues; do not use real trading credentials or place actual trades. The linked workspace report is `research/synpath-public-testing/PRE6_VERIFICATION.md`; it records observed results and limitations. + +## Affected pages and routes + +- Standalone SDK trading app: `POST /orders`, `GET /orders`, `GET /orders/{id}`, `PATCH /orders/{id}` where supported, and `DELETE /orders/{id}`. Confirm mounted prefixes for any other server configuration rather than assuming `/trading`. +- Bucket report/preview routes: use the existing router's paths and test fixtures; assert complete-parent visibility across all captured accounts. +- SDK event socket and replay: verify parent and report events under complete and partial grants. +- Terminal at `http://127.0.0.1:5173`: select bucket, execution ticket and routed preview. Use deterministic market-data fixtures. Order submission remains disabled. + +## Key interactions to verify + +1. Submit a bucket with two named accounts through `Engine.submit()` and through HTTP. Observe null parent execution venue/account, stable user identity and a saved map of eligible execution accounts. Verify exact concrete venue/account on requests received by each scripted adapter. +2. Reverse otherwise identical member order with distinct prices. Compare owners, account maps, permissions, risk decisions and economic allocation, excluding generated IDs/timestamps. +3. Make the non-first venue strictly cheaper and deep enough for the whole order. Observe all fills there while parent execution venue/account remain null. +4. Flip a member. Verify its actual outcome, side and YES price are converted correctly; parent quantities/prices remain in bucket terms. +5. Revoke one member-account grant, halt a non-first venue, and exceed its position limit in separate scenarios. Observe denial according to the agreed full-capacity admission policy before dispatch. +6. Interleave a bucket edit after permission checks. Observe execution only on the originally authorized captured members/accounts. +7. Submit resting children, cancel the parent, delay one cancellation acknowledgement, then deliver a final status or fill. Observe pending cleanup until children resolve and accurate aggregate fills. +8. Restart working, cancelling and finished parents. Observe original scope/IDs, lookup and reports; account configuration changes never redirect new sends. +9. On the terminal, enter a quantity and worst price for deterministic two-venue books. Observe correct routed preview legs and remainder; reverse members and repeat. Capture screenshots with the entered fields and resulting legs visible. + +## Edge cases + +- Missing/inactive bucket, no enabled members, missing adapter, mismatched account venue and caller-supplied singular account. +- Partial view/trade grants: refuse or hide complete aggregate/report and scoped events, while preserving authorized individual-child access. +- Same venue-native child ID on two distinct venues, duplicate child status messages, partial/full fills during cancellation. +- Venue/account filters and pagination across live and finished parents: return each matching parent once, use saved membership, and handle incompatible old cursors explicitly. +- Conservative capacity example: order 100 with capacity 60 on each venue is rejected even though a 50/50 split would fit. Label this intentional policy in the report. +- Missing/conflicting legacy account evidence: explicit preflight/migration failure, no guessed current account. +- Same-client-ID retry where supported: original identity/scope and no duplicate send. Document any unsupported existing bucket retry behavior rather than claim an untested guarantee. + +## Critical paths + +| Path | Test suite / evidence | Required result | +|---|---|---| +| SDK submit to actual child requests | `tests/test_routed_limit.py` and captured request/response snippets | Exact venue accounts; member-order invariance; flipped correctness | +| HTTP permissions to dispatch | `tests/test_server_trading.py`, `tests/test_serve_buckets.py` | No unauthorized sends or parent aggregate leakage | +| Captured snapshot under concurrent edit | HTTP/engine interleaving fixture | Authorized snapshot equals executed snapshot | +| Atomic parent save | `tests/test_order_storage.py` plus portable PostgreSQL scenarios | Inject failure/cancellation between each write; all rows/events roll back | +| Legacy migration | `tests/test_owner_migrations.py` on SQLite/PostgreSQL | Preserve identities/fills, one rekey, unsafe mapping fails transactionally | +| Cancellation and restart | `tests/test_routed_recovery.py` and existing pending-cancellation regressions | No premature completion or new allocation while cancelling | +| Query scaling | Membership-filter query plans and page query counts | Indexed membership and bounded queries per page | +| Terminal integration | Backend unittest, consumer smoke, contract check, frontend build | Compatible installed candidate SDK and unchanged preview-only terminal | +| Browser evidence | Deterministic bucket screenshots | Visible inputs, venue legs and remainder; no simulated order presented as a live trade | + +## Evidence and commands + +Run SDK targeted tests first, then the existing offline SDK suite with its live/demo exclusions, pure-Python variant and required native/Rust checks. PostgreSQL scenarios require a disposable database through `SYNPATH_TEST_POSTGRES_DSN`; a skipped database suite is not a pass. Require CI's PostgreSQL job or equivalent actual local results. + +Workspace checks use its existing commands: + +```sh +uv run --project terminal --locked python -m unittest discover -s terminal/backend/tests -v +uv run --project terminal --locked python research/synpath-public-testing/check_install.py +npm --prefix terminal/frontend run check:contracts +npm --prefix terminal/frontend run build +``` + +Candidate SDK worktrees are deliberately on a feature branch. The setup script currently requires a clean clone on the experimental branch and `.git` to be a directory. Use a documented isolated candidate-install arrangement for pre-merge tests; do not reset the original checkout, claim that setup verified a feature worktree, or add a general setup bypass. After merging the SDK PR, run the normal workspace setup and CI against the resulting experimental branch tip and record its actual hash. + +For every scenario, save the exact command or short runnable input snippet, expected result, captured sanitized output, pass/fail/not-run status and limitation. Use fixture account names only. Screenshots must be actual captures, linked by scenario; no mockup may stand in for observed behavior. Record SDK/workspace hashes, schema revision, test totals and CI links. Report actual child fills as simulated, never real. Link the final requirement-to-evidence report and screenshot assets from both PRs before final landing. diff --git a/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md b/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md new file mode 100644 index 0000000..0916a24 --- /dev/null +++ b/synpath/engine/orders/lifecycle/routed-lifecycle-plan.md @@ -0,0 +1,102 @@ +# Routed order lifecycle improvement plan + +Status: existing statuses retained; the user-authorized routed cancellation fix is implemented with offline regression tests. Stop-reason consistency and broader saving/recovery changes remain future work. No schema or public status changes were made. Read [shared managed states](managed-states.md) and [current routed behavior](routed.md) first. + +## Agreed direction + +- Keep the current internal states and public status mapping, including `working` to `triggered`. This is a naming tradeoff, not an execution defect. Document `triggered` as active managed execution for routed orders; a routed parent has no stop-price trigger. +- Keep phase, reason and filled quantity separate. `canceled` with `filled=30`, `remaining=70` and `stop_reason=liquidity` is a useful and coherent outcome for 100 requested. +- Add consistent stop reasons to the to-do list. Record gaps now; do not implement them yet. +- Examine lifecycle transitions next, especially pending cancellation. Saving and recovery follow after the intended transitions are understood. + +## Stop reason to do list + +- [ ] List the current endings: user cancel, halt, worst price, liquidity, minimum quantity, round limit, maximum age, explicit expiry and execution errors. +- [ ] Specify a structured reason for each applicable ending so clients do not parse `detail` text. Existing routing reasons remain useful. +- [ ] Define which reason wins if events overlap, such as user cancellation followed by expiry or a full fill during cancellation. +- [ ] Decide what to retain when cancellation is still pending: the original stopping cause, intended final state and latest child outcomes. +- [ ] Specify where those fields appear in a normal order response versus a bucket report before changing contracts. +- [ ] Add offline tests after the transition rules are agreed. + +The issue is consistent population across paths, not a need to combine reason and fill progress into one status. + +## Current lifecycle in plain language + +| Step | Internal state | Public status | Relevant code | +|---|---|---|---| +| Parent is constructed | `waiting` | `waiting` | `ManagedOrder.__init__()` | +| Routing starts | `working` | `triggered` | `RoutedLimit.start()` | +| Book changes or part of the quantity fills | Usually still `working` | `triggered` | `on_book()`, `on_fill()`, `on_child_order_updated()`, `_replan()` | +| User cancellation begins | `cancelling` | `pending_cancel` | Routed `cancel()` | +| Every child is resolved, requested quantity filled | `done` | `closed` | Completion paths | +| Every child is resolved, some requested quantity remains | `canceled`, or `expired` for explicit expiry | Same as internal state | Cancellation or expiry path | + +Routing-boundary `stop()` sets `stop_reason` and now uses the same routed `cancel()` lifecycle: enter `cancelling` before venue calls and continue cleanup until tracked children end. `max_age` currently ends `canceled` when incomplete, while explicit expiry requests `expired`. Whether to align these endings remains open; neither is changed by this plan. + +## Walkthrough of delayed cancellation + +Example: buy 100; 30 filled and 70 remain resting at one venue. + +1. User cancels. Routed `cancel()` sets `cancelling`, records the intended final state and saves before requesting child cancellation. +2. The venue acknowledges but reports the child open. The child remains live, so the parent remains `cancelling` with 30 filled. +3. Later, the venue reports the child canceled. The controller updates the child through `update_child_from_venue_order()` before invoking parent callbacks. +4. `on_child_order_updated()` handles `cancelling` separately: remove inactive legs, check remaining live children, and finish with the retained ending if none remain. Full quantity filled wins and ends `done`. +5. If no final update arrives, timer ticks retry child cancellation without placing replacements. + +Before the fix, `_ready()` allowed only `working`, so both child and ordinary timer callbacks returned without completing the pending parent. The regression tests reproduce those failures on the committed readability checkpoint and pass with the fix. + +## Transition rules to review + +Tracked-child cancellation rules below are implemented and tested. Uncertain submissions outside the tracked-child list and failure-ending policy remain separate review items. + +| Transition or observation | Intended rule | +|---|---| +| `waiting` to `working` | Start this accepted routed instruction. | +| `working` after a partial fill | Update quantities; continue only for the remainder. | +| `working` to `cancelling` | Stop allocating before child cancellation calls, whether the cause is user action or a routing boundary. | +| Child update while `cancelling` | Keep recording progress, clean up child state and check whether the pending ending can complete. Never resume routing. | +| Timer while `cancelling` | Retry necessary cleanup or check progress; do not place replacement orders. | +| `cancelling` to `done` | Full quantity filled and no live or unresolved child can add exposure. | +| `cancelling` to `canceled` or `expired` | Incomplete, but children are resolved; use the intended ending policy. | +| Any proposed final state | No outstanding child can still change exposure without being represented. Failure is not proof of rejection. | + +If there is nothing left to cancel, stopping can finish within the same call. No extra state is needed just to show a partial fill. Whether a failure after partial execution should end `canceled` or `rejected` remains an explicit decision, not an accepted change. + +```mermaid +stateDiagram-v2 + [*] --> waiting + waiting --> working: start + working --> working: partial fill or revised allocation + working --> done: fully filled and children resolved + working --> cancelling: request stopping + cancelling --> cancelling: children still open or unresolved + cancelling --> done: final fills complete the instruction + cancelling --> canceled: incomplete and cancellation confirmed + cancelling --> expired: incomplete and expiry cleanup complete +``` + +This focuses on normal execution and stopping. Safe rejection and pre-parent validation failure need their own reviewed cases. `stop()` now enters `cancelling` before requesting venue cancellation. + +## Offline verification checklist + +- [ ] Ordinary full fill and partial fill retain correct quantities and existing public statuses. +- [ ] Immediate cancellation completes with the expected quantity and reason. +- [x] Cancellation acknowledgment still showing open stays pending. +- [x] A later child cancellation completes that pending parent. +- [x] A cancellation failure is retried without allocating new quantity. +- [x] A final fill during cancellation yields full completion; a partial fill is retained while cancellation continues. +- [x] Book updates and reentrant child callbacks during stopping cannot submit replacements. +- [x] Routing-boundary stop and explicit expiry obey the same cleanup rules, with their chosen final-state policy. +- [ ] No-books and error paths do not imply confirmed cancellation or safe rejection. + +Use existing paper/scripted venues. Do not place credentialed or real-money orders for this work. + +## Bridge to saving and recovery + +Once the transition rules are agreed, identify the information a restart must preserve: execution phase, stopping cause, intended final outcome, child identities and each child's last known progress or uncertainty. Then examine how to store it and how to reconcile it before sending anything new. + +That later review covers pending child identity, response loss, unavailable venue reads, transaction boundaries and migration choices. This plan does not choose a new table, remove the shared order copy or implement recovery changes. Bucket synchronization, amendments, additional bucket order types and lifetime-setting support remain separate scope. + +## Next code reading session + +Read routed `cancel()` and `_finish_cancellation_if_children_stopped()`, then the shared `cancel_live_children_without_waiting()`. Follow a venue result through `ManagedOrderController.on_order()` into `RoutedLimit.on_child_order_updated()`. Compare its cancelling branch with its working branch, and read `_refresh_restored_children_if_needed()` separately. These call sites show why entering a state is only half of a lifecycle: something must also advance it to its ending. diff --git a/synpath/engine/orders/lifecycle/routed.md b/synpath/engine/orders/lifecycle/routed.md new file mode 100644 index 0000000..c52a5c0 --- /dev/null +++ b/synpath/engine/orders/lifecycle/routed.md @@ -0,0 +1,79 @@ +# Routed bucket order lifecycle + +Status: current behavior, not the proposed contract. Read alongside [shared managed states](managed-states.md) and [the draft improvement plan](routed-lifecycle-plan.md). + +## Accepted instruction + +[`Engine._submit_bucket()`](../../engine.py) accepts only `type="market"` with a required `price`: the worst accepted price in bucket terms. The bucket must be saved and active, have an enabled member, and have adapters for its enabled venues. The parent captures the enabled-member definition; later bucket edits do not change that order. + +[`RoutedLimit`](../routed.py) manages this instruction despite its historical class name. Its optional settings include `min_stay_s`, `max_rounds`, `max_age_s` and precision overrides. Zero routing limits mean disabled bounds. Size and price validation also comes from `OrderRequest` and risk checks. These are parent instructions; children are priced GTC limit orders. + +## Parent identity and execution accounts + +The parent has no execution venue or singular account. Its stable user owns the +instruction; `execution_accounts` captures exact accounts for the enabled members. +Each child selects its concrete venue/account from that saved scope. Member order +cannot change ownership, risk admission or permissions. Admission conservatively +checks full quantity at each eligible member and actual children are checked again. +Authorization and execution use one captured snapshot. Single-account bucket +requests are rejected. See [engine upgrade instructions](../../README.md#cross-venue-parent-identity-and-upgrade-preflight) +for migration, legacy account mappings and compatibility details. + +Parent snapshot, account membership, readable record and journal events commit +atomically. Parent lookup uses managed identity even after completion/restart; +actual venue orders retain their existing keys. Scope guards protect parent detail, +reports, actions, HTTP audit history and live/replayed events. Account mismatch +blocks affected recovery reads and cleanup as well as new dispatch, leaving the +activity unresolved until its original account is available. + +## Current transitions + +| Observation or action | Current transition and behavior | +|---|---| +| Construct parent | Begins `waiting`; it has no price trigger to wait for. | +| `start()` | Sets `working`, records start time, and plans immediately. Public status is currently `triggered`. | +| Books change or child progress arrives | While ready and `working`, replan remaining quantity and keep, cancel or add children. A partial fill normally leaves the parent `working`. | +| No member books available | Remain `working`, publish `reason=no_books`, save and wait. | +| Full fill | Completion paths use `done` / public `closed`. `complete_if_done()` also requires no live children. Routed full-fill paths now use cancellation cleanup if other tracked children remain live. | +| Plan has an unfilled remainder and a reason, with no live children | `stop(reason)` ends `canceled` if incomplete. Reasons include `worst_price`, `liquidity`, `min_quantity`. | +| `max_rounds` reached | `stop("max_rounds")` enters `cancelling` before venue calls, then ends `canceled` if incomplete once tracked children have ended. | +| `max_age_s` reached on timer | Same stop path with `max_age`; currently an incomplete order ends `canceled`, not `expired`. | +| User cancel or halt | Routed `cancel()` enters `cancelling` before venue calls, retains the intended final state, and finishes after all tracked children have ended. Timer ticks retry pending cancellations. | +| Explicit `expires_at` reached | Shared timer calls inherited `expire()`, requesting final state `expired` after children are stopped. | +| Error during a controller callback | Shared `_run_hook()` handles it. A parent can become `rejected`; uncertainty handling is not equivalent for all order types. | + +`max_rounds` counts later planning rounds that actually placed or pulled an order. The first placement does not increment it. `max_age_s` is elapsed time from `start()`, checked by the timer; it is not a venue-native order expiry. + +Explicit expiry plumbing exists, so “bucket orders can never expire” is inaccurate. Bucket submission bypasses the ordinary DAY-to-absolute-expiry conversion, however, and bucket-specific time-in-force semantics are not fully specified. GTD, DAY, IOC and FOK acceptance and behavior need dedicated decisions and tests. + +## Current outcome fields + +`as_order()` exposes public status, filled and remaining quantity. `report()` additionally exposes internal `state`, `stop_reason`, `filled`, `unfilled`, rounds and per-venue results. A stopped partial fill is structured data, not only prose. + +For 100 requested, 30 filled and no liquidity remaining, the routed report can say `state=canceled`, `filled=30`, `unfilled=70`, `stop_reason=liquidity`. A client can display “30/100 filled · stopped: liquidity”. This does not require a separate partial-fill state. + +The user-cancel/expiry path does not populate routed `stop_reason` in the same way as `stop()`. General error paths also use detail text. The reason is therefore not consistent across every ending. + +## Lifecycle behavior to review + +- The shared mapping names active routing `triggered`. We are keeping that mapping; document it as active managed execution rather than treating it as a defect. +- `max_age` and explicit expiry use different final states for time-based endings. +- Stopping now marks `cancelling` before any cancellation calls. Reentrant callbacks process child progress without resuming routing. +- `on_child_order_updated()` and `on_fill()` can finish a cancelling parent after its last tracked child ends. `on_timer()` retries cancellation when a response still reports open or a request fails. +- The intended incomplete ending (`pending_final_state`) is included in the existing managed snapshot so delayed explicit expiry remains `expired`. The first stopping cause/end is retained across repeated cancellation attempts. +- Shared error paths still need a separate review before claiming all unresolved venue submissions prevent finalization. This fix handles tracked children, not every unknown-send case. +- End reasons differ between routing stop, user cancellation, expiry and errors. + +Offline regression tests in `tests/test_routed_limit.py: TestPendingCancellation` reproduce delayed cancellation, fill races, retries and reentrant callbacks. Public status names, routing reasons and `max_age` semantics are unchanged. + +## What resync means + +`resync()` attempts to refresh child order records after the Engine restarts. It fetches each known child from its venue, replaces saved status/progress, and adopts additional children linked to the parent in the journal. A working parent can then consider its remainder; a cancelling parent only continues cleanup. + +`_resynced` means this refresh has been attempted in the current process. New parents set it true in `start()` because they have no restored children. Restored parents begin false. The method sets it true before venue reads and currently logs/skips failed reads or missing adapters. Therefore true does **not** mean all children were successfully refreshed. + +`_refresh_restored_children_if_needed()` performs that attempt independently of lifecycle state. Routing callbacks then check `state == "working"`; cancelling callbacks instead advance cleanup. There is no longer a working-only `_ready()` gate that drops stopping updates. + +## Next phase boundary + +The Engine owns the instruction; venues own child acceptance and fills. Routing progress and the intended pending ending use the managed snapshot path. PRE-6 adds account membership and rekeys parent projections through migration 0006; it does not change lifecycle states. The following phase must assess saved child identities, failed venue reads, uncertain sends and cancellation races across process failure before stronger recovery guarantees can be claimed. diff --git a/synpath/engine/orders/lifecycle/stop-limit.md b/synpath/engine/orders/lifecycle/stop-limit.md new file mode 100644 index 0000000..6075373 --- /dev/null +++ b/synpath/engine/orders/lifecycle/stop-limit.md @@ -0,0 +1,40 @@ +# Stop limit order + +Required fields are market ID, side, quantity, `stop_price` and `price`. +A sell limit is at or below its stop; a buy limit is at or above it. +Trigger source is touch, last or mid. TIF is one for the whole order, armed +and after trigger, as in IBKR and FIX: GTC, GTD (needs expiry) or DAY (held as +GTD at the session end, an absolute expiry persisted at submission). IOC and +FOK are refused. + +Creation persists waiting without a venue order, then reads the book the +engine already holds: a stop already met triggers at once. Subscribing the +market does the same, since a venue sends no fresh snapshot for a market the +shared connection already carries. A `last` stop waits for a print made after +it. A threshold observation +moves irreversibly to triggered and plans a limit child using the requested +price, the parent's TIF and expiry (GTC stays GTC; GTD and DAY go as GTD at +the same `expires_at`) and remaining quantity. The child can rest after a price +gap. Parent progress is derived from child results and ends closed, canceled, +rejected or expired. Cancel after trigger pulls the resting child; remain +pending until its outcome is known. Expiry (GTD or DAY) applies before +trigger and to the working child: a parent that expires with its child resting +cancels the child and ends `expired`. Children inherit user identity and the order's outcome and action. + +`stop_limit_orders` owns the instruction and durable state. The trigger and +pending child intent commit together. Stable child identity survives restart; +uncertain submissions are reconciled before any repeat effect. Expiry is +checked before triggering and before resuming an unsent limit child. +Cancellation that reports a still-open child remains pending and is retried. +If the venue answers the child's cancel with "not found", the child is read +back: a child that filled ends the parent `closed` with its filled quantity, +not `canceled`. A child the venue has no record of keeps the parent +`pending_cancel` and is retried; it is never assumed cancelled. A finished +parent is never sent to the venue, before or after a restart. +Example: sell 100 with stop 0.40 and limit 0.38; a bid gap to 0.35 can leave +the triggered limit unfilled. + +See [shared storage and ownership](../../README.md). + +A `post_only` stop-limit propagates that flag to its child. The venue may +reject a child that would take liquidity. diff --git a/synpath/engine/orders/lifecycle/stop-market.md b/synpath/engine/orders/lifecycle/stop-market.md new file mode 100644 index 0000000..a9347fe --- /dev/null +++ b/synpath/engine/orders/lifecycle/stop-market.md @@ -0,0 +1,40 @@ +# Stop market order + +Required fields are market ID, side, quantity and `stop_price`. Trigger source +is touch (default), last or mid. TIF is one for the whole order, as in IBKR +and FIX: GTC, GTD (needs expiry) or DAY (held as GTD at the session end). IOC +and FOK are refused. It bounds the armed period; the triggered child is IOC +and ends on its own within it. + +Creation persists a waiting parent without contacting the venue, then reads +the book the engine already holds, as does subscribing the market: a stop +already met triggers without waiting for the next change. Buy fires +when the watched YES price reaches or exceeds the stop; sell fires when it +reaches or falls below it. Touch uses the YES ask for buys and bid for sells. +The irreversible triggered state dispatches one market IOC child for the +remaining quantity. The child uses the legal venue grid bound for its side, using registered +market precision when available. An unready, crossed or missing executable +book prevents market dispatch. + +The parent ends closed (including recorded IOC remainder), canceled, +rejected or expired. No executable book at a last-trade trigger rejects it. +Expiry applies while armed. A child inherits owner and the order's outcome and action +(a Sell YES stop's child sells YES held). Cancel before trigger ends +without a child. Pending child actions must be reconciled after restart. + +`stop_market_orders` owns the request and durable state. The trigger and +stable pending child intent commit atomically before sending. Startup restores +waiting parents, expires overdue armed stops, and reconciles uncertain +children before resuming. Cancellation remains pending until child status +is known. A cancel never relabels a fill: a parent whose IOC child filled is +already `closed` and stays so, and once a restart leaves it unloaded a cancel +returns its journal record without contacting the venue. The parent reports actual execution averages and IOC remainder. Example: sell 100 at stop 0.40 after the +YES bid falls to 0.39. + +See [shared storage and ownership](../../README.md). + +Polymarket US clamps children to its absolute 0.01–0.99 YES bounds even +when the tick is finer. Its create response remains pending until a venue +order update confirms execution. A decoder exception after sending preserves +the pending dispatch; it does not establish rejection. See shared verification +limits for venues without native client-key recovery. diff --git a/synpath/engine/orders/manager.py b/synpath/engine/orders/manager.py index 5b159de..776577b 100644 --- a/synpath/engine/orders/manager.py +++ b/synpath/engine/orders/manager.py @@ -1,6 +1,6 @@ """The supervisor: every engine-held order, and what reaches it. -One `ManagedOrders` per engine. It creates parents, hands them the events +One `ManagedOrderController` per engine. It creates parents, hands them the events they asked for, writes their state down whenever it changes, and brings them back after a restart. Three details are worth stating, because they are what makes an engine-held order safe rather than a background task: @@ -24,6 +24,9 @@ from decimal import Decimal from typing import Any, Iterable, Mapping, Type +from ...errors import NetworkError +from ...trading.errors import SubmissionPending +from ..storage_types import IntentState from ...trading.types import Fill, Order, OrderRequest, OrderType from .base import Child, Context, ManagedOrder, State, now_ms @@ -38,15 +41,16 @@ def register(cls: Type[ManagedOrder]) -> Type[ManagedOrder]: return cls -class ManagedOrders: +class ManagedOrderController: """Every engine-held order this engine is running.""" def __init__(self, engine: Any): self.engine = engine self.parents: dict[str, ManagedOrder] = {} + # Child order id to parent id. self.by_child: dict[str, str] = {} - """Child order id to parent id.""" self.by_market: dict[str, set[str]] = {} + self._running_tasks: dict[str, Any] = {} # -- lookup --------------------------------------------------------------- @@ -74,7 +78,9 @@ def watching(self, market_id: str) -> list[ManagedOrder]: # -- creating ------------------------------------------------------------- - def build(self, request: OrderRequest, *, venue: str, account: Any) -> ManagedOrder: + def build(self, request: OrderRequest, *, venue: str | None, account: Any, + execution_accounts: dict | None = None) -> ManagedOrder: + """Construct the requested order implementation without saving or starting it.""" # `managed_kind` lets one venue type map to an engine-held variant: # a market order the engine walks the book with, rather than the # adapter's single immediate limit. @@ -85,23 +91,42 @@ def build(self, request: OrderRequest, *, venue: str, account: Any) -> ManagedOr f"{kind} is an engine-held order type with no implementation loaded; " f"known: {sorted(REGISTRY)}" ) - return cls(request, venue=venue, account=account) - - async def create(self, request: OrderRequest, *, venue: str, account: Any, owner: ManagedOrder | None = None) -> ManagedOrder: - parent = self.build(request, venue=venue, account=account) + return cls(request, venue=venue, account=account, execution_accounts=execution_accounts) + + async def create(self, request: OrderRequest, *, venue: str | None, account: Any, + execution_accounts: dict | None = None, owner: ManagedOrder | None = None) -> ManagedOrder: + """Save and register an engine-held order, announce it, then start its behavior. + + `owner` is another managed order, not a user: for example, a bracket + can own a stop order. The returned order may already have placed or + filled venue children, or encountered a failure while starting. + """ + # Choose the implementation (for example, RoutedLimit for a bucket). + # A managed order can itself be a child of another managed order. + parent = self.build(request, venue=venue, account=account, execution_accounts=execution_accounts) parent.owner_id = owner.id if owner else None + + # Persist the initial instruction before start() can send venue orders. + await self.save(parent) + + # Register in-memory lookups so market and child updates reach this order. + # The owner link also lets a nested managed order report back to its owner. self.remember(parent) if owner is not None: self.by_child[parent.id] = owner.id - await self.save(parent) + + # Creation means the order exists, not that a venue has accepted a child. await self.engine.bus.publish("managed.created", { "parent_id": parent.id, "kind": parent.kind, "market_id": parent.market_id, "side": parent.side.value, "quantity": str(parent.quantity), "book": request.book, }, key=parent.id) + # Await the type-specific start behavior through the controller's locking, + # error handling and final save. A routed order plans and places children here. await self.run(parent, lambda ctx: parent.start(ctx)) return parent def remember(self, parent: ManagedOrder) -> None: + """Index an order by ID, watched markets and known children, in memory only.""" self.parents[parent.id] = parent for instrument in parent.markets(): self.by_market.setdefault(instrument, set()).add(parent.id) @@ -115,15 +140,42 @@ def forget(self, parent: ManagedOrder) -> None: # -- routing -------------------------------------------------------------- async def run(self, parent: ManagedOrder, call: Any) -> None: + """Run one order's behavior without overlapping another task for that order. + + The callback receives a Context for books, child submission and saving. + Child notifications on the current task may call back into the same order. + """ + task = asyncio.current_task() + if self._running_tasks.get(parent.id) is task: + await self._run_hook(parent, call) + return + async with self.engine._lock(f"managed:{parent.id}"): + self._running_tasks[parent.id] = task + try: + await self._run_hook(parent, call) + finally: + self._running_tasks.pop(parent.id, None) + + async def _run_hook(self, parent: ManagedOrder, call: Any) -> None: """Run one hook, catching what it raises so the others keep working.""" ctx = self.context(parent) try: + if parent.execution_accounts: + for venue, account in parent.execution_accounts.items(): + self.engine.assert_execution_account(venue, account) await call(ctx) + except SubmissionPending as exc: + parent.detail = str(exc) + except (NetworkError, asyncio.TimeoutError): + parent.detail = "venue action is unresolved; waiting for reconciliation" except Exception as exc: log.exception("synpath.engine.orders: %s %s failed", parent.kind, parent.id) parent.detail = f"{type(exc).__name__}: {exc}" try: - await parent.finish(ctx, "rejected", parent.detail) + if await self._dispatch_is_uncertain(parent): + parent.detail += "; venue dispatch is unresolved; refusing to reject or resend" + else: + await parent.finish(ctx, "rejected", parent.detail) except Exception: # pragma: no cover - the journal is the only thing left log.exception("synpath.engine.orders: could not record the failure of %s", parent.id) finally: @@ -133,6 +185,14 @@ async def run(self, parent: ManagedOrder, call: Any) -> None: if not parent.live and parent.owner_id: await self.tell_owner(parent) + async def _dispatch_is_uncertain(self, parent: ManagedOrder) -> bool: + """A decoder/host failure after sending cannot prove venue rejection.""" + pending = getattr(parent, "pending_child", None) + if pending is None or not pending.client_order_id: + return False + intent = await self.engine.journal.intent(pending.client_order_id) + return intent is not None and intent.state in (IntentState.SENDING, IntentState.SENT) + async def tell_owner(self, leg: ManagedOrder) -> None: """A leg of a bracket or an OCO ended: its owner decides what that means.""" owner = self.parents.get(leg.owner_id or "") @@ -143,14 +203,29 @@ async def tell_owner(self, leg: ManagedOrder) -> None: return child.status = leg.state child.filled = leg.filled - await self.run(owner, lambda ctx: owner.on_child(ctx, leg.as_order(), child)) + await self.run(owner, lambda ctx: owner.on_child_order_updated(ctx, leg.as_order(), child)) async def on_book(self, market_id: str) -> None: - for parent in self.watching(market_id): - await self.run(parent, lambda ctx, p=parent: p.on_book(ctx, market_id)) + watching = self.watching(market_id) + observations = {} + for parent in watching: + if parent.kind in ("stop_market", "stop_limit") and parent.state == "waiting": + if parent.trigger_source in ("touch", "mid"): + observations[parent.id] = parent.watched(self.context(parent)) + for parent in watching: + if parent.kind in ("stop_market", "stop_limit", "trailing_stop") and parent.state != "waiting": + continue + if parent.id in observations: + price = observations[parent.id] + if price is not None: + await self.run(parent, lambda ctx, p=parent, value=price: p.consider(ctx, value)) + else: + await self.run(parent, lambda ctx, p=parent: p.on_book(ctx, market_id)) async def on_trade(self, market_id: str, price: Decimal, quantity: Decimal) -> None: for parent in self.watching(market_id): + if parent.kind in ("stop_market", "stop_limit", "trailing_stop") and parent.state != "waiting": + continue await self.run(parent, lambda ctx, p=parent: p.on_trade(ctx, market_id, price, quantity)) async def on_order(self, order: Order) -> None: @@ -158,28 +233,34 @@ async def on_order(self, order: Order) -> None: parent learns its children's fills from: the record is a snapshot, the latest overwrites, and the progress since the last one drives `on_fill`. Fill events are the ledger's and never reach a parent.""" - parent = self.parent_of(order.id, order.venue) + parent = self.parent_of(order.id, order.venue) or self.parents.get(order.parent_id or "") if parent is None: return - child = parent.child_of(order.id, order.venue) - if child is None: - return - progress = parent.note_order(order, child) - if progress is not None: - await self.run(parent, lambda ctx: parent.on_fill(ctx, progress, child)) - await self.run(parent, lambda ctx: parent.on_child(ctx, order, child)) + async def apply(ctx): + child = parent.child_of(order.id, order.venue) + if child is None: + return + # First update the tracked child, even when no new quantity filled. + progress = parent.update_child_from_venue_order(order, child) + if progress is not None: + await parent.on_fill(ctx, progress, child) + # Then let the parent react to the updated child. A correct child + # record alone does not advance the parent's lifecycle state. + await parent.on_child_order_updated(ctx, order, child) + await self.run(parent, apply) async def on_timer(self) -> None: - now_ms = int(self.engine.clock() * 1000) for parent in self.live(): - expires_at = parent.request.expires_at - if expires_at is not None and now_ms >= expires_at: - # The parent's own expiry. Its type decides what that ends: - # by default the whole order, every live child pulled. - await self.run(parent, lambda ctx, p=parent: p.expire(ctx)) - if not parent.live or parent.state == "cancelling": - continue - await self.run(parent, lambda ctx, p=parent: p.on_timer(ctx)) + async def tick(ctx, p=parent): + expires_at = p.request.expires_at + if expires_at is not None and int(self.engine.clock() * 1000) >= expires_at: + await p.expire(ctx) + if not p.live or p.state == "cancelling": + if p.live: + await p.on_timer(ctx) + return + await p.on_timer(ctx) + await self.run(parent, tick) async def on_halt(self, reason: str, *, scope: str = "*") -> int: halted = 0 @@ -200,11 +281,11 @@ async def cancel(self, parent_id: str, *, reason: str = "cancelled by the caller # -- persistence ---------------------------------------------------------- async def save(self, parent: ManagedOrder) -> None: + """Save the restart snapshot and readable order record, and update local lookups.""" for child in parent.children: self.by_child.setdefault(child.order_id, parent.id) self.by_child.setdefault(f"{child.venue}:{child.order_id}", parent.id) - await self.engine.journal.save_managed(parent.snapshot()) - await self.engine.journal.upsert_order(parent.as_order(), event=f"managed.{parent.state}") + await self.engine.journal.save_managed(parent.snapshot(), order=parent.as_order()) self.engine._remember(parent.as_order()) async def restore(self) -> int: @@ -222,3 +303,10 @@ async def restore(self) -> int: if restored: await self.engine.bus.publish("managed.restored", {"count": restored}) return restored + + async def resume_restored(self) -> None: + """Resume persisted stop actions only after intent reconciliation.""" + for parent in self.live(): + recover = getattr(parent, "recover", None) + if recover is not None: + await self.run(parent, lambda ctx, p=parent: p.recover(ctx)) diff --git a/synpath/engine/orders/oco.py b/synpath/engine/orders/oco.py index da78627..c4f9c79 100644 --- a/synpath/engine/orders/oco.py +++ b/synpath/engine/orders/oco.py @@ -44,11 +44,14 @@ def leg_request(spec: Mapping[str, Any], *, parent: ManagedOrder, quantity: Deci "trader": parent.request.trader, "tags": {**parent.request.tags, "parent": parent.id}, } - for key in ("price", "stop_price", "trigger_source", "time_in_force", "expires_at", "post_only", "reduce_only", "params"): + for key in ("price", "stop_price", "trigger_source", "time_in_force", "expires_at", "post_only", "outcome", + "action", "params"): if spec.get(key) is not None: body[key] = spec[key] - if "reduce_only" not in body and parent.request.reduce_only: - body["reduce_only"] = True + if "outcome" not in body and Side(body["side"]) == parent.side and parent.request.outcome is not None: + # A leg on the parent's side does what the parent does (a Sell YES + # bracket's exits sell YES held); one on the other side keeps its own. + body["outcome"], body["action"] = parent.request.outcome, parent.request.action return OrderRequest.model_validate(body) @@ -132,7 +135,7 @@ async def cancel_other(self, ctx: Context, tag: str, reason: str) -> None: child.status = "canceled" await ctx.publish("managed.leg_canceled", {"leg": tag, "reason": reason}) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if not order.is_terminal or self.state != "working": return if self.remaining <= 0: @@ -169,8 +172,8 @@ def __init__(self, *args: Any, **kwargs: Any): self.stop_loss = dict(self.params.get("stop_loss") or {}) if not self.take_profit and not self.stop_loss: raise ValueError("a bracket needs params take_profit, stop_loss, or both") + # Contracts the entry has filled, which is what the protection covers. self.entered: Decimal = ZERO - """Contracts the entry has filled, which is what the protection covers.""" self.protected: Decimal = ZERO def markets(self) -> list[str]: @@ -227,7 +230,7 @@ def entry_done(self) -> bool: entry_children = self.leg_children("entry") return bool(entry_children) and not any(c.live for c in entry_children) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: tag = self.leg_of.get(child.order_id, "") if self.state != "working" or not order.is_terminal: return diff --git a/synpath/engine/orders/peg.py b/synpath/engine/orders/peg.py index 18f47e9..bf773cf 100644 --- a/synpath/engine/orders/peg.py +++ b/synpath/engine/orders/peg.py @@ -141,7 +141,7 @@ async def reprice(self, ctx: Context, *, first: bool = False) -> None: async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: await self.complete_if_done(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if self.state != "working" or not order.is_terminal: return if self.remaining <= 0: diff --git a/synpath/engine/orders/routed.py b/synpath/engine/orders/routed.py index bef5b05..690873a 100644 --- a/synpath/engine/orders/routed.py +++ b/synpath/engine/orders/routed.py @@ -33,20 +33,20 @@ from ...bucket import Bucket, bucket_id_of from ...trading.types import Fill, Order, OrderRequest, OrderType, Precision, Side, TimeInForce from .. import router -from .base import ZERO, Child, Context, D, ManagedOrder, now_ms +from .base import ZERO, Child, Context, D, ManagedOrder, State, now_ms from .manager import register +# Larger reference orders asked for while a venue's floor hides its curve: +# 100, then 10,000, a million and 100 million contracts. FEE_REFERENCE_TRIES = 4 -"""Larger reference orders asked for while a venue's floor hides its curve: -100, then 10,000, a million and 100 million contracts.""" +# Contracts in the order a level's fee is quoted for, then divided back to +# one contract. A one-contract quote overstates the rate wherever a venue +# charges per order: Kalshi rounds each order's fee up to the cent, and +# Opinion charges at least 0.25 USDT an order, which would read as 25c a +# contract and keep the router off the venue entirely. The floor itself is +# kept per member and applied to whole legs by the plan. FEE_REFERENCE = Decimal("100") -"""Contracts in the order a level's fee is quoted for, then divided back to -one contract. A one-contract quote overstates the rate wherever a venue -charges per order: Kalshi rounds each order's fee up to the cent, and -Opinion charges at least 0.25 USDT an order, which would read as 25c a -contract and keep the router off the venue entirely. The floor itself is -kept per member and applied to whole legs by the plan.""" @dataclass @@ -56,8 +56,8 @@ class LegState: order_id: str venue: str side: str + # The member's own YES price, as sent. price: Decimal - """The member's own YES price, as sent.""" bucket_price: Decimal quantity: Decimal placed_at: float @@ -77,8 +77,10 @@ def of(cls, row: dict[str, Any]) -> "LegState": @register class RoutedLimit(ManagedOrder): - """`params`: `bucket` (its definition; the engine fills this in from the - journal), `min_stay_s`, `max_rounds`, `max_age_s`, `precision`.""" + """`params`: `bucket` (the enabled-member configuration captured at + submission and kept for this order, including after restart), `min_stay_s`, + `max_rounds`, `max_age_s`, `precision`. Later edits to the saved bucket + do not change this order's member configuration.""" kind = "routed_limit" order_type = OrderType.MARKET @@ -103,17 +105,21 @@ def __init__(self, *args: Any, **kwargs: Any): self.rounds = 0 self.started_at = 0.0 self.stop_reason = "" + # The incomplete order's intended ending while child cancellations are pending. + self.pending_final_state: State = "canceled" self.fees_paid = ZERO self.fee_cache: dict[str, Decimal] = {} + # Per member, the least one taker order pays, as its venue reported. self.fee_floors: dict[str, Decimal] = {} - """Per member, the least one taker order pays, as its venue reported.""" """`market_id@price` -> taker fee per contract.""" self.fee_unknown: set[str] = set() self._replanning = False self._replan_again = False + # Whether refresh of restored children has been attempted in this process. + # New parents set this in start() because they have no restored children. + # resync() sets it before reading venues; failed reads are logged and + # skipped, so True does not prove every child's status is current. self._resynced = False - """False until this parent has asked the venues about its legs after - a restart. A fresh parent sets it in `start`.""" # -- identity: the bucket, and every member --------------------------------- @@ -145,9 +151,12 @@ def known_filled(self) -> Decimal: def known_remaining(self) -> Decimal: return self.remaining - def budget(self) -> Decimal: - """Size new legs may add: what is left after every live leg's whole - size is reserved and every closed leg's fill is counted.""" + def remaining_quantity_after_fills_and_outstanding_orders(self) -> Decimal: + """Quantity available for new orders after fills and live reservations. + + A live child's original quantity covers both its fills and its remaining + outstanding quantity. An ended child reserves only its filled quantity. + """ reserved = sum((c.quantity if c.live else self.known(c) for c in self.children), ZERO) return max(ZERO, self.quantity - reserved) @@ -160,24 +169,31 @@ async def start(self, ctx: Context) -> None: await self.replan(ctx, first=True) async def resync(self, ctx: Context) -> None: - """After a restart: the venues are the authority on what each leg - did while this process was away. Ask every venue for every child, - take in any child the journal links to this parent that the - snapshot did not carry (an intent that was in doubt when the - process died), then plan the remainder. Nothing is placed before - this has run.""" + """Refresh saved child records after a restart before acting on them. + + Read each known child from its venue, replace locally saved status and + progress, and adopt additional children linked in the journal. A working + parent then considers its remainder; a cancelling parent only resumes + cleanup through its caller, never routing. + + This is an attempted refresh, not an all-venues-success barrier: + _resynced is set before reads, and unavailable or failing venues are + currently skipped. Stronger failed-read recovery is separate work. + """ self._resynced = True for child in list(self.children): adapter = ctx.engine.adapters.get(child.venue) if adapter is None: continue try: + if self.execution_accounts: + ctx.engine.assert_execution_account(child.venue, self.execution_accounts[child.venue]) order = await adapter.fetch_order(child.order_id) except Exception as exc: await ctx.publish("managed.resync_failed", {"order_id": child.order_id, "venue": child.venue, "error": f"{type(exc).__name__}: {exc}"}) continue - progress = self.note_order(order, child) # the record read back overwrites the snapshot's + progress = self.update_child_from_venue_order(order, child) # the record read back overwrites the snapshot's if progress is not None: self.fees_paid += progress.fee or ZERO known = {self.key(c) for c in self.children} @@ -188,7 +204,7 @@ async def resync(self, ctx: Context) -> None: _, bucket_price = member.to_bucket(order.side, order.price or ZERO) child = self.track(order, order.quantity, bucket_price) child.fee = order.fee or ZERO - ctx.engine.orders.adopt_child(self.id, order.id, order.venue) + ctx.engine.managed_order_controller.adopt_child(self.id, order.id, order.venue) if child.live and order.market_id not in self.legs: self.legs[order.market_id] = LegState(order_id=order.id, venue=order.venue, side=order.side.value, price=order.price or ZERO, bucket_price=bucket_price, @@ -197,43 +213,57 @@ async def resync(self, ctx: Context) -> None: await ctx.publish("managed.resynced", {"children": len(self.children), "filled": str(self.known_filled)}) if self.state == "working": if self.remaining <= 0: - await self.pull_children(ctx) - await self.finish(ctx, "done", "filled") + await self.cancel(ctx, "filled") else: await self.replan(ctx) - async def _ready(self, ctx: Context) -> bool: + async def _refresh_restored_children_if_needed(self, ctx: Context) -> None: + """Attempt restart refresh independently of whether this parent may route.""" if not self._resynced: await self.resync(ctx) - return self.state == "working" async def on_book(self, ctx: Context, market_id: str) -> None: - if await self._ready(ctx): + await self._refresh_restored_children_if_needed(ctx) + if self.state == "working": await self.replan(ctx) async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: self.fees_paid += fill.fee or ZERO - if not await self._ready(ctx): + await self._refresh_restored_children_if_needed(ctx) + if self.state == "cancelling": + await self._finish_cancellation_if_children_stopped(ctx) + return + if self.state != "working": return if self.remaining <= 0: - await self.pull_children(ctx) - await self.finish(ctx, "done", "filled") + await self.cancel(ctx, "filled") return await self.replan(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: - if not await self._ready(ctx) or not order.is_terminal: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: + """Advance stopping children, or replan an active order after a child ends.""" + # The controller already recorded the child's venue status and progress. + # A stopping parent still needs those results to finish; it must not route. + await self._refresh_restored_children_if_needed(ctx) + if self.state == "cancelling": + await self._finish_cancellation_if_children_stopped(ctx) return - for market_id, leg in list(self.legs.items()): - if leg.order_id == order.id and leg.venue == order.venue: - self.legs.pop(market_id, None) + if self.state != "working" or not order.is_terminal: + return + self._forget_dead_legs() if self.remaining <= 0: await self.complete_if_done(ctx) else: await self.replan(ctx) async def on_timer(self, ctx: Context) -> None: - if not await self._ready(ctx): + await self._refresh_restored_children_if_needed(ctx) + if self.state == "cancelling": + # An open cancellation response or a failed request needs another + # attempt, even if no more book or order notifications arrive. + await self.cancel(ctx, self.detail, end=self.pending_final_state) + return + if self.state != "working": return if self.max_age_s and ctx.now - self.started_at >= self.max_age_s: await self.stop(ctx, "max_age") @@ -241,127 +271,273 @@ async def on_timer(self, ctx: Context) -> None: if not self.live_children and self.remaining > 0: await self.replan(ctx) - async def stop(self, ctx: Context, reason: str) -> None: - """Stand down with what filled. `done` only when it all did.""" - self.stop_reason = reason - await self.pull_children(ctx) + async def cancel(self, ctx: Context, reason: str = "cancelled", *, end: State = "canceled") -> None: + """Stop allocating first, then request child cancellation and await later results.""" + if not self.live: + return + if self.state != "cancelling": + # Mark stopping before any await that can deliver a child callback. + # Later retries preserve the original ending (including explicit expiry). + self.pending_final_state = end + self.detail = reason + self.state = "cancelling" + await ctx.save() + await self._refresh_restored_children_if_needed(ctx) + await self.cancel_live_children_without_waiting(ctx) + await self._finish_cancellation_if_children_stopped(ctx) + + async def _finish_cancellation_if_children_stopped(self, ctx: Context) -> None: + """Complete only after all tracked children ended; a full fill wins over cancellation.""" + if self.state != "cancelling": + return + self._forget_dead_legs() + if self.live_children: + return if self.remaining <= 0: await self.finish(ctx, "done", "filled") - elif self.live_children: - self.state = "cancelling" - await ctx.save() else: - await self.finish(ctx, "canceled", f"stopped: {reason}") + await self.finish(ctx, self.pending_final_state, self.detail) + + async def stop(self, ctx: Context, reason: str) -> None: + """Apply a routing boundary using the same pending-cancellation lifecycle.""" + if not self.live: + return + if self.state != "cancelling": + self.stop_reason = reason + await self.cancel(ctx, f"stopped: {reason}") # -- the plan and the legs ------------------------------------------------ - def may_move(self, ctx: Context, leg: LegState) -> bool: + def minimum_stay_has_elapsed(self, ctx: Context, leg: LegState) -> bool: + """Whether this leg has rested long enough to allow routine cancel/replacement.""" return ctx.now - leg.placed_at >= self.min_stay_s async def replan(self, ctx: Context, *, first: bool = False) -> None: - """Re-entrant safe: a cancel inside a plan can hand the engine a - terminal child, whose `on_child` asks for a plan of its own. That - request is noted and honoured once this plan is through.""" + """Run planning passes in sequence, remembering requests received during a pass. + + Cancelling a child can synchronously deliver an order update, whose + callback calls replan() before the current pass returns. The controller + permits these callbacks on the same task, so its per-order lock alone + does not prevent nested planning. These flags defer that work until the + current pass finishes updating the legs and quantity reservations. + """ + # A stopping or finished parent must not allocate more venue orders. + if self.state != "working": + return + + # The active pass owns the leg changes. Remember that another pass is + # needed, then return to it instead of recursively changing the same legs. + # Several nested requests share one flag because the next pass reads + # the latest state rather than replaying each notification separately. if self._replanning: self._replan_again = True return + + # Set the guard before awaiting anything that could call back into us. self._replanning = True try: + # Only the initial placement uses first=True; later reallocations + # are eligible to increment the parent's routing-round counter. await self._replan(ctx, first=first) + + # Honour deferred requests after the current pass settles. A pass + # may also stop the parent, in which case no further routing is allowed. while self._replan_again and self.state == "working": + # Consume this request before awaiting: a callback during the + # next pass must be able to request yet another pass. self._replan_again = False await self._replan(ctx) finally: + # Release the guard even on failure, and leave no deferred request + # attached to this invocation when a later event calls replan(). self._replanning = False self._replan_again = False async def _replan(self, ctx: Context, *, first: bool = False) -> None: + """Reconcile a fresh proposal with existing children in one planning pass. + + First remove inactive leg references and trim live children's outstanding + quantity to fit the parent quantity still needed. Then plan from available + books for target minus all known fills, initially ignoring outstanding orders. + + For each tracked child, keep it unchanged if its side and price match the + proposal and its outstanding quantity fits within the remaining target, + even when its size differs from the proposed size. Otherwise cancel it + once its minimum stay has elapsed. Keep children with no current book + because missing data alone does not justify cancellation. + + Then add orders from the remaining proposals, skipping member markets + where a child was kept. Reserve all live children's outstanding quantity + before adding orders so the total does not exceed the requested quantity. + Current leg tracking is per member market, not per venue. + + Report and save the result, or begin stopping when a routing bound is met. + Callbacks can change lifecycle state during awaits; only working parents + may continue allocating. The replan() wrapper handles deferred passes. + """ + if self.state != "working": + return if self.known_remaining <= 0: - # Everything is filled by the venue's word; the fills the stream - # has not delivered yet finish it through `on_fill`. - await self.complete_if_done(ctx) + await self.cancel(ctx, "filled") + return + + canceled_count = await self._clean_up_existing_legs(ctx) + member_books = self._read_member_books(ctx) + if not member_books: + await self._save_while_waiting_for_books(ctx, canceled_count=canceled_count, first=first) + return + + plan = await self._calculate_routing_plan(ctx, member_books) + proposed_legs = {leg.market_id: leg for leg in plan.legs} + canceled_count += await self._keep_or_cancel_existing_legs(ctx, member_books, proposed_legs) + if self.state != "working": return + placed_count = await self._place_new_legs(ctx, proposed_legs) + await self._record_routing_result( + ctx, plan, placed_count=placed_count, canceled_count=canceled_count, first=first, + ) + + async def _clean_up_existing_legs(self, ctx: Context) -> int: + """Clean up tracked allocations before calculating a fresh proposal. + + First remove leg metadata whose child is missing or no longer live. + Then cancel excess outstanding child quantity so it fits within the + parent's remaining target. Return the number of children canceled. + """ self._forget_dead_legs() - pulled = await self._trim(ctx) - books = {} + return await self._trim(ctx) + + def _read_member_books(self, ctx: Context) -> dict[str, router.BookView]: + """Read available member books, with flipped members expressed in bucket terms.""" + member_books = {} for member in self.bucket.members: book = ctx.book(member.market_id) if book is not None: - books[member.market_id] = router.view(book, member) - if not books: - # No book for any member yet (a restart before the streams are - # back): nothing to plan against, and no reason to pull what - # rests beyond what the trim took. Wait for a book. - if not first and pulled: - self.rounds += 1 - await ctx.publish("managed.routed", {"round": self.rounds, "placed": 0, "pulled": pulled, - "resting": len(self.legs), "remaining": str(self.known_remaining), - "reason": "no_books"}) - await ctx.save() - return - await self._price_fees(ctx, books) - plan = router.plan(self.bucket, books, self.precision, self._fee, - side=self.side, quantity=self.known_remaining, limit=self.limit, floors=self.fee_floors) - wanted = {leg.market_id: leg for leg in plan.legs} - - # Keep a leg that is still at a price the plan wants, as long as what - # is kept does not add up to more than is left; pull one whose price - # is wrong, unless it has not sat for min_stay_s yet. Shrinking a leg - # is a cancel and a re-place at the back of the queue, so a leg is - # not pulled merely because the plan would size it smaller today. A - # kept leg is not topped up in the same round. - kept_resting = ZERO + member_books[member.market_id] = router.view(book, member) + return member_books + + async def _save_while_waiting_for_books( + self, ctx: Context, *, canceled_count: int, first: bool, + ) -> None: + """Keep resting orders when no books are available; save cleanup progress and wait.""" + # A restart may restore children before their market streams return. + # Missing books alone do not justify cancelling those resting orders. + if not first and canceled_count: + self.rounds += 1 + await ctx.publish("managed.routed", { + "round": self.rounds, "placed": 0, "pulled": canceled_count, + "resting": len(self.legs), "remaining": str(self.known_remaining), "reason": "no_books", + }) + await ctx.save() + + async def _calculate_routing_plan( + self, ctx: Context, member_books: dict[str, router.BookView], + ) -> router.Plan: + """Price the available liquidity and propose legs for all remaining contracts.""" + await self._price_fees(ctx, member_books) + return router.plan( + self.bucket, member_books, self.precision, self._fee, + side=self.side, quantity=self.known_remaining, limit=self.limit, floors=self.fee_floors, + ) + + async def _keep_or_cancel_existing_legs( + self, ctx: Context, member_books: dict[str, router.BookView], + proposed_legs: dict[str, router.Leg], + ) -> int: + """Keep suitable children and cancel movable ones that no longer fit. + + Remove each kept market from `proposed_legs` in place, so the placement + stage cannot submit another child there during this round. + """ + canceled_count = 0 + # Outstanding quantity retained so far by the missing-book and matching-price checks. + kept_resting_quantity = ZERO for market_id, leg in list(self.legs.items()): child = self.child_of(leg.order_id, leg.venue) if child is None or not child.live: self.legs.pop(market_id, None) continue - want = wanted.get(market_id) - resting = child.quantity - self.known(child) - if market_id not in books: - # No view of this member's book: nothing says the leg is wrong. - kept_resting += resting - wanted.pop(market_id, None) + proposed_leg = proposed_legs.get(market_id) + resting_quantity = child.quantity - self.known(child) + + # No current book for this market: keep its child, because missing + # data alone is not evidence that the resting order should move. + if market_id not in member_books: + kept_resting_quantity += resting_quantity + proposed_legs.pop(market_id, None) continue - if (want is not None and want.price == leg.price and want.side == leg.side - and kept_resting + resting <= self.known_remaining): - kept_resting += resting - wanted.pop(market_id) + + # Same side and price, with room in the remaining quantity: keep + # the queue position. Do not shrink to the proposal's size, since + # cancel/replacement would lose priority, or top it up this round. + if (proposed_leg is not None and proposed_leg.price == leg.price and proposed_leg.side == leg.side + and kept_resting_quantity + resting_quantity <= self.known_remaining): + kept_resting_quantity += resting_quantity + # Skip new placement on this market because its existing child is kept. + proposed_legs.pop(market_id) continue - if not self.may_move(ctx, leg): - wanted.pop(market_id, None) + + # The proposal no longer fits, but the minimum stay has not passed. + # Keep the child for now; its outstanding quantity remains reserved. + if not self.minimum_stay_has_elapsed(ctx, leg): + proposed_legs.pop(market_id, None) continue + + # The child no longer fits and is old enough to move. Cancel it; + # any replacement will come from the remaining proposed legs. self.legs.pop(market_id, None) await ctx.cancel_child(child.order_id, child.venue) child.status = "canceled" - pulled += 1 - - # Never put out more than is left once every live leg is reserved. - budget = self.budget() - placed = 0 - for market_id, want in wanted.items(): - size = min(want.quantity, budget) - spec = self.precision[market_id] - if size <= 0 or size < spec.min_quantity: + canceled_count += 1 + return canceled_count + + async def _place_new_legs(self, ctx: Context, proposed_legs: dict[str, router.Leg]) -> int: + """Submit remaining proposals within the unreserved quantity and venue minimums.""" + # Reserve every live child's size before adding orders, including those + # kept despite missing books or an unelapsed minimum stay. + available_quantity = self.remaining_quantity_after_fills_and_outstanding_orders() + placed_count = 0 + for market_id, proposed_leg in proposed_legs.items(): + # A venue response can call back into the parent and start stopping. + if self.state != "working": + break + quantity = min(proposed_leg.quantity, available_quantity) + precision = self.precision[market_id] + if quantity <= 0 or quantity < precision.min_quantity: continue - if spec.min_notional is not None and want.notional() * size / want.quantity < spec.min_notional: + if (precision.min_notional is not None + and proposed_leg.notional() * quantity / proposed_leg.quantity < precision.min_notional): continue - request = self.child_request(market_id=market_id, side=want.side, price=want.price, quantity=size, - type=OrderType.LIMIT, time_in_force=TimeInForce.GTC) + request = self.child_request( + market_id=market_id, side=proposed_leg.side, price=proposed_leg.price, quantity=quantity, + type=OrderType.LIMIT, time_in_force=TimeInForce.GTC, + ) order = await ctx.submit_child(request) - child = self.track(order, size, want.bucket_price) # the child's price in bucket terms + # Record both the venue's response and the intended allocation. + # Child progress uses bucket prices; LegState retains the native price. + child = self.track(order, quantity, proposed_leg.bucket_price) child.fee = order.fee or ZERO - self.legs[market_id] = LegState(order_id=order.id, venue=order.venue, side=want.side.value, price=want.price, - bucket_price=want.bucket_price, quantity=size, placed_at=ctx.now, - round=self.rounds) - budget -= size - placed += 1 - if not first and (placed or pulled): - # A round is a re-allocation that changed something. The re-plans - # a cancel's own echo triggers change nothing and do not count. + self.legs[market_id] = LegState( + order_id=order.id, venue=order.venue, side=proposed_leg.side.value, price=proposed_leg.price, + bucket_price=proposed_leg.bucket_price, quantity=quantity, placed_at=ctx.now, + round=self.rounds, + ) + available_quantity -= quantity + placed_count += 1 + return placed_count + + async def _record_routing_result( + self, ctx: Context, plan: router.Plan, *, placed_count: int, canceled_count: int, first: bool, + ) -> None: + """Report the round, stop at the applicable bounds, otherwise save working state.""" + if self.state != "working": + return + # Count only later rounds that changed an order. Replans caused by a + # cancellation's own notification do not count unless they change something. + if not first and (placed_count or canceled_count): self.rounds += 1 await ctx.publish("managed.routed", { - "round": self.rounds, "placed": placed, "pulled": pulled, "resting": len(self.legs), + "round": self.rounds, "placed": placed_count, "pulled": canceled_count, "resting": len(self.legs), "remaining": str(self.known_remaining), "unfilled": str(plan.unfilled), "reason": plan.reason, "expected_net_price": str(plan.expected_net_price) if plan.expected_net_price is not None else None, @@ -404,6 +580,18 @@ def newest_first(item: Any) -> tuple[int, int, float]: return pulled def _forget_dead_legs(self) -> None: + """Remove current-leg metadata that no longer points to a live child. + + self.legs tracks an allocation already submitted on each member market: + its child order ID, side, price, quantity and placement information. + It is separate from the fresh proposal returned by router.plan(). + + A leg is dead when its child is missing or its recorded status is no + longer live, for example after a full fill or cancellation. Remove only + the entry in self.legs; retain the child and its fills in self.children + so they still count toward parent progress. This method sends no venue + requests and does not cancel orders. + """ for market_id, leg in list(self.legs.items()): child = self.child_of(leg.order_id, leg.venue) if child is None or not child.live: @@ -501,6 +689,7 @@ def extra(self) -> dict[str, Any]: "precision": {k: v.model_dump(mode="json") for k, v in self.precision.items()}, "legs": {k: v.to_dict() for k, v in self.legs.items()}, "rounds": self.rounds, "started_at": self.started_at, "stop_reason": self.stop_reason, + "pending_final_state": self.pending_final_state, "fees_paid": str(self.fees_paid), "fee_cache": {k: str(v) for k, v in self.fee_cache.items()}, "fee_floors": {k: str(v) for k, v in self.fee_floors.items()}, } @@ -518,6 +707,11 @@ def load_extra(self, extra: dict[str, Any]) -> None: self.rounds = int(extra.get("rounds") or 0) self.started_at = float(extra.get("started_at") or 0) self.stop_reason = extra.get("stop_reason") or "" + # Older explicit-expiry snapshots used the fixed detail "expired" but + # did not store an intended ending separately. Preserve that ending. + self.pending_final_state = extra.get("pending_final_state") or ( + "expired" if self.detail == "expired" else "canceled" + ) self.fees_paid = D(extra.get("fees_paid"), ZERO) self.fee_cache = {k: D(v) for k, v in (extra.get("fee_cache") or {}).items()} self.fee_floors = {k: D(v) for k, v in (extra.get("fee_floors") or {}).items()} diff --git a/synpath/engine/orders/stop.py b/synpath/engine/orders/stop.py index dcd212a..47f845a 100644 --- a/synpath/engine/orders/stop.py +++ b/synpath/engine/orders/stop.py @@ -15,51 +15,59 @@ actually execute. `last` (the last public trade) and `mid` are there for callers who disagree, and the journal records which was used. -**Stop market** (`type="stop_market"`). `time_in_force` is how long the stop -stays armed: `gtc` (the default), `gtd` until `expires_at`, or `day` until -the engine's session end; `ioc` and `fok` are refused. A stop whose time -runs out before it triggers ends `expired`. The child is a `market` order, +**Time in force** is one for the whole order, as IBKR and FIX use it: how +long the stop lives, armed and after it triggers. `gtc` (the default), `gtd` +until `expires_at`, or `day` until the engine's session end (held as `gtd` +with that absolute expiry, so it outlives a restart); `ioc` and `fok` are +refused on a stop. + +**Stop market** (`type="stop_market"`). A stop whose time runs out before it +triggers ends `expired`. The child is a `market` order, `ioc`. No venue here takes a market order without a worst price, so the -engine sets one itself -- the touch plus `PROTECTION` for a buy, minus it -for a sell, kept inside the price range -- and the caller never does. With +engine sets the legal venue price bound for the side, using the cached +market precision when supplied and the venue grid otherwise. The caller +never supplies a slippage cap. With no book to price against at the moment of triggering, the stop ends `rejected` rather than send an order that would take whatever appears. A child that fills ends the stop `closed`; an IOC remainder that does not fill ends it with the unfilled quantity recorded. **Stop limit** (`type="stop_limit"`). The child is a `limit` at `price` -(at or below `stop_price` for a sell, at or above it for a buy) with the -order's own `time_in_force` (`gtc`, `gtd`, `ioc`, `fok`) and `expires_at`, -and it may rest. A `gtd` stop that expires before it triggers, or with its -child still resting, ends `expired`; a cancel ends it `canceled` before or -after the trigger; a child the venue refuses ends it `rejected`. +(at or below `stop_price` for a sell, at or above it for a buy) that +inherits the order's time in force and expiry (`gtc`, or `gtd` at the same +`expires_at`, which is where a `day` stop's session end lives), and it may +rest. A stop whose time runs out before it triggers, or with its child still +resting, ends `expired`, the child canceled first; a cancel ends it +`canceled` before or after the trigger; a child the venue refuses ends it +`rejected`. **Trailing** ratchets: a sell stop follows the highest bid seen, a buy stop the lowest ask, never the other way. The distance is `trail` in price units or `trail_percent` of the watched price, and the current stop is persisted, so a restart resumes from the level the market actually reached rather than from where it started. With a `price` it sends a limit; without one, a -market child priced the same way as a stop market's. +market child with its existing five-cent touch protection. **Statuses**, as the parent `Order` reports them: `waiting`, `triggered`, then `closed`, `canceled`, `rejected` or `expired`. """ from __future__ import annotations +import uuid from decimal import Decimal from typing import Any from ...trading.instruments import VENUE_RULES from ...trading.money import round_to -from ...trading.types import Fill, Order, OrderStatus, OrderType, Side, TimeInForce, TriggerSource +from ...trading.types import Fill, Order, OrderRequest, Precision, OrderStatus, OrderType, Side, TimeInForce, TriggerSource +from ..storage_types import IntentState from .base import Child, Context, D, ManagedOrder from .manager import register TRIGGER_SOURCES = tuple(source.value for source in TriggerSource) -PROTECTION = Decimal("0.05") -"""How far past the touch a triggered market child may fill: the worst -price the engine sends with it. Not the caller's to set.""" +# Legacy trailing-stop protection; stop-market uses legal venue bounds. +TRAILING_PROTECTION = Decimal("0.05") class _Stop(ManagedOrder): @@ -73,6 +81,9 @@ def __init__(self, *args: Any, **kwargs: Any): raise ValueError(f"trigger_source must be one of {TRIGGER_SOURCES}, not {self.trigger_source!r}") self.triggered_at: int | None = None self.trigger_price: Decimal | None = None + self.pending_child: OrderRequest | None = None + self.pending_precision: Precision | None = None + self.pending_end: str | None = None # -- the watched price ---------------------------------------------------- @@ -87,17 +98,29 @@ def reached(self, price: Decimal) -> bool: """A buy stop fires when the price rises to it, a sell stop when it falls.""" return price >= self.stop_price if self.side == Side.BUY else price <= self.stop_price + async def start(self, ctx: Context) -> None: + """A book the engine already holds may meet the stop now, and a quiet + market sends no change to say so. A past print does not: `last` + waits for one made after the stop.""" + await self.on_book(ctx, self.market_id) + async def on_book(self, ctx: Context, market_id: str) -> None: if self.trigger_source in ("touch", "mid"): await self.consider(ctx) async def on_trade(self, ctx: Context, market_id: str, price: Decimal, quantity: Decimal) -> None: if self.trigger_source == "last": + timestamp = ctx.last_trade_timestamp(market_id) + if timestamp is not None and timestamp < self.created_ms: + return await self.consider(ctx, price) async def consider(self, ctx: Context, price: Decimal | None = None) -> None: if self.state != "waiting": return + if self.request.expires_at is not None and int(ctx.now * 1000) >= self.request.expires_at: + await self.expire(ctx) + return watched = price if price is not None else self.watched(ctx) if watched is None: return @@ -114,33 +137,113 @@ async def trigger(self, ctx: Context, watched: Decimal) -> None: self.state = "working" self.triggered_at = int(ctx.now * 1000) self.trigger_price = watched - await ctx.publish("managed.triggered", { - "kind": self.kind, "stop_price": str(self.stop_price), "trigger_price": str(watched), - "trigger_source": self.trigger_source, - }) await self.send_child(ctx, watched) async def send_child(self, ctx: Context, watched: Decimal) -> None: raise NotImplementedError - def protection(self, ctx: Context) -> Decimal | None: - """The worst price a triggered market child may fill at: the touch - plus `PROTECTION` for a buy, minus it for a sell, on the venue's tick - and inside (0, 1). `None` with no book to price against: a last-trade - trigger can fire on a market nobody is quoting, and an order sent - then would take whatever appears.""" + def child_price_bound(self, ctx: Context) -> Decimal | None: + """Stop market takes all available prices on the instrument's legal + grid. Trailing stops retain their existing touch protection policy. + No ready, uncrossed executable book means no market dispatch.""" touch = ctx.touch(self.side) if touch is None: return None - tick = VENUE_RULES.get(self.venue, {}).get("default_tick") or Decimal("0.01") + precision = ctx.registered_precision() or Precision( + tick=VENUE_RULES.get(self.venue, {}).get("default_tick") or Decimal("0.01")) + tick = precision.tick + if self.kind == "stop_market": + self.pending_precision = precision + rules = VENUE_RULES.get(self.venue, {}) + if self.side == Side.SELL: + return round_to(max(tick, rules.get("min_price", tick)), tick, "up") + ceiling = round_to(precision.face_value, tick, "down") + ceiling = ceiling - tick if ceiling == precision.face_value else ceiling + return round_to(min(ceiling, rules.get("max_price", ceiling)), tick, "down") if self.side == Side.BUY: - return min(round_to(touch + PROTECTION, tick, "down"), 1 - tick) - return max(round_to(touch - PROTECTION, tick, "up"), tick) + return min(round_to(touch + TRAILING_PROTECTION, tick, "down"), 1 - tick) + return max(round_to(touch - TRAILING_PROTECTION, tick, "up"), tick) + + async def submit_planned_child(self, ctx: Context, request: OrderRequest) -> None: + """Persist one stable dispatch identity with the trigger before sending.""" + identity = f"{self.request.user_id}:{self.account_key}:{self.id}:stop-child" + request = request.model_copy(update={"client_order_id": f"sp-{uuid.uuid5(uuid.NAMESPACE_URL, identity).hex[:20]}"}) + self.pending_child = request + await ctx.plan_child(request) + await ctx.save() + await ctx.publish("managed.triggered", {"kind": self.kind, "trigger_source": self.trigger_source, + "stop_price": str(self.stop_price), "trigger_price": str(self.trigger_price), + "child_client_order_id": request.client_order_id}) + if not ctx.defer_pending_action(): + await self.resume_pending(ctx) + + async def resume_pending(self, ctx: Context, *, allow_send: bool = True) -> None: + request = self.pending_child + if request is None: + return + if request.type == OrderType.LIMIT and request.expires_at is not None and int(ctx.now * 1000) >= request.expires_at: + self.pending_end = "expired" + self.state = "cancelling" + self.detail = "expired before pending dispatch completed" + allow_send = False + await ctx.save() + order, intent = await ctx.observe_pending_child(request.client_order_id) + if order is None and intent is not None and intent.state == IntentState.PLANNED: + if not allow_send: + intent = await ctx.settle_unsent_child(request.client_order_id) + else: + order = await ctx.submit_child(request, precision=self.pending_precision) + if order is not None: + if self.child_of(order.id, order.venue) is None: + self.track(order, request.quantity, request.price) + self.pending_child = None + self.pending_precision = None + await ctx.save() + if self.pending_end: + await self.cancel(ctx, self.detail, end=self.pending_end) + else: + await self.on_child_order_updated(ctx, order, self.child_of(order.id, order.venue)) + return + if intent is not None and intent.state in (IntentState.REJECTED, IntentState.FAILED, IntentState.SETTLED): + self.pending_child = None + self.pending_precision = None + await self.finish(ctx, self.pending_end or "rejected", intent.detail or "child was not accepted") + else: + self.detail = "child submission is unresolved; refusing to repeat the venue send" + await ctx.save() + + async def recover(self, ctx: Context) -> None: + expires_at = self.request.expires_at + expired = expires_at is not None and int(ctx.now * 1000) >= expires_at + if expired and (self.state == "waiting" or self.kind != "stop_market"): + await self.expire(ctx) + if self.live: + await self.on_timer(ctx) + + async def on_timer(self, ctx: Context) -> None: + if (self.pending_child is not None or self.state == "cancelling") and ctx.defer_pending_action(): + return + if self.pending_child is not None: + await self.resume_pending(ctx, allow_send=self.state == "working" and self.pending_end is None) + elif self.state == "cancelling": + await self.cancel(ctx, self.detail, end=self.pending_end or "canceled") + + async def cancel(self, ctx: Context, reason: str = "cancelled", *, end: str = "canceled") -> None: + if not self.live: + return + self.pending_end = end + self.detail = reason + if self.pending_child is not None: + self.state = "cancelling" + await ctx.save() + await self.resume_pending(ctx, allow_send=False) + return + await super().cancel(ctx, reason, end=end) async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: await self.complete_if_done(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if not order.is_terminal or self.state != "working": return if self.remaining <= 0: @@ -174,6 +277,9 @@ async def expire(self, ctx: Context) -> None: def extra(self) -> dict[str, Any]: return { "stop_price": str(self.stop_price), "trigger_source": self.trigger_source, + "pending_child": self.pending_child.model_dump(mode="json") if self.pending_child else None, + "pending_precision": self.pending_precision.model_dump(mode="json") if self.pending_precision else None, + "pending_end": self.pending_end, "triggered_at": self.triggered_at, "trigger_price": str(self.trigger_price) if self.trigger_price is not None else None, } @@ -183,25 +289,26 @@ def load_extra(self, extra: dict[str, Any]) -> None: self.trigger_source = extra.get("trigger_source", self.trigger_source) self.triggered_at = extra.get("triggered_at") self.trigger_price = D(extra.get("trigger_price")) + self.pending_child = OrderRequest.stored(extra["pending_child"]) if extra.get("pending_child") else None + self.pending_precision = Precision.model_validate(extra["pending_precision"]) if extra.get("pending_precision") else None + self.pending_end = extra.get("pending_end") @register class StopMarket(_Stop): - """Fires an immediate-or-cancel child at the protection price.""" + """Fires one IOC child within the legal venue price bounds.""" kind = "stop_market" order_type = OrderType.STOP_MARKET async def send_child(self, ctx: Context, watched: Decimal) -> None: - price = self.protection(ctx) + price = self.child_price_bound(ctx) if price is None: await self.finish(ctx, "rejected", "no book to price the market order against: refusing to send at any price") return request = self.child_request(quantity=self.remaining, price=price, type=OrderType.MARKET, time_in_force=TimeInForce.IOC) - order = await ctx.submit_child(request) - self.track(order, request.quantity, price) - await ctx.save() + await self.submit_planned_child(ctx, request) async def expire(self, ctx: Context) -> None: """`time_in_force` is how long the stop stays armed. Once triggered, @@ -219,12 +326,12 @@ class StopLimit(_Stop): async def send_child(self, ctx: Context, watched: Decimal) -> None: price = D(self.request.price) + # One time in force for the whole order: the child lives exactly as + # long as the stop does (a `day` stop is already `gtd` here). request = self.child_request(quantity=self.remaining, price=price, type=OrderType.LIMIT, time_in_force=self.request.time_in_force or TimeInForce.GTC, - expires_at=self.request.expires_at) - order = await ctx.submit_child(request) - self.track(order, request.quantity, price) - await ctx.save() + expires_at=self.request.expires_at, post_only=self.request.post_only) + await self.submit_planned_child(ctx, request) @register @@ -240,8 +347,8 @@ def __init__(self, *args: Any, **kwargs: Any): self.trail_percent = D(self.params.get("trail_percent")) if self.trail is None and self.trail_percent is None: raise ValueError("a trailing stop needs params trail or trail_percent") + # The best price seen: the highest for a sell stop, the lowest for a buy. self.extreme: Decimal | None = None - """The best price seen: the highest for a sell stop, the lowest for a buy.""" def distance(self, watched: Decimal) -> Decimal: if self.trail is not None: @@ -266,16 +373,14 @@ async def adjust(self, ctx: Context, watched: Decimal) -> None: await ctx.save() async def send_child(self, ctx: Context, watched: Decimal) -> None: - price = D(self.request.price) or self.protection(ctx) + price = D(self.request.price) or self.child_price_bound(ctx) if price is None: await self.finish(ctx, "rejected", "no book to price the market order against: refusing to send at any price") return kind = OrderType.LIMIT if self.request.price is not None else OrderType.MARKET request = self.child_request(quantity=self.remaining, price=price, type=kind, time_in_force=TimeInForce.IOC if kind == OrderType.MARKET else TimeInForce.GTC) - order = await ctx.submit_child(request) - self.track(order, request.quantity, price) - await ctx.save() + await self.submit_planned_child(ctx, request) def extra(self) -> dict[str, Any]: return super().extra() | { diff --git a/synpath/engine/orders/taker.py b/synpath/engine/orders/taker.py index 4c1f915..919bbee 100644 --- a/synpath/engine/orders/taker.py +++ b/synpath/engine/orders/taker.py @@ -120,7 +120,7 @@ async def start(self, ctx: Context) -> None: if not await self.take(ctx, self.remaining): await self.finish(ctx, "canceled", "nothing on the book inside the price bound") - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if self.state != "working" or not order.is_terminal: return if self.remaining <= 0: @@ -169,7 +169,7 @@ async def on_timer(self, ctx: Context) -> None: return await self.clip_now(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if self.state == "working" and self.remaining <= 0 and not self.live_children: await self.finish(ctx, "done", "filled") diff --git a/synpath/engine/orders/twap.py b/synpath/engine/orders/twap.py index 891c3ab..64a4422 100644 --- a/synpath/engine/orders/twap.py +++ b/synpath/engine/orders/twap.py @@ -49,10 +49,10 @@ def __init__(self, *args: Any, **kwargs: Any): self.style = str(self.params.get("style") or "limit") if self.style not in ("limit", "taker"): raise ValueError("a TWAP's style is 'limit' or 'taker'") + # The worst price any slice may pay. `None` means the market's. self.limit = D(self.params.get("limit")) or D(self.request.price) - """The worst price any slice may pay. `None` means the market's.""" + # What the end of the window does with anything unfilled. self.at_end = str(self.params.get("finish") or "complete") - """What the end of the window does with anything unfilled.""" self.started_at: float = 0.0 self.sent_slices = 0 @@ -165,7 +165,7 @@ async def finish_window(self, ctx: Context) -> None: async def on_fill(self, ctx: Context, fill: Fill, child: Child) -> None: await self.complete_if_done(ctx) - async def on_child(self, ctx: Context, order: Order, child: Child) -> None: + async def on_child_order_updated(self, ctx: Context, order: Order, child: Child) -> None: if self.state != "working" or not order.is_terminal: return if self.remaining <= 0: diff --git a/synpath/engine/paper.py b/synpath/engine/paper.py index 126a032..8e39ce7 100644 --- a/synpath/engine/paper.py +++ b/synpath/engine/paper.py @@ -117,8 +117,8 @@ class Resting: """A resting paper order and where it stands in the queue.""" order: Order + # Contracts that must trade at this price before this order fills. queue_ahead: Decimal - """Contracts that must trade at this price before this order fills.""" request: OrderRequest @@ -143,7 +143,7 @@ class PaperVenue(TradingExchange): "cancel_all_orders": True, "edit_order": True, "fetch_order": True, "fetch_open_orders": True, "fetch_orders": True, "fetch_my_trades": True, "fetch_positions": True, "fetch_balance": True, "fetch_settlements": False, "fetch_queue_position": True, "fetch_fee_estimate": True, - "rfq": False, "split_merge": False, + "rfq": False, "rfq_taker": False, "rfq_quoter": False, "combos": False, "split_merge": False, "watch_orders": False, "watch_my_trades": False, "watch_positions": False, "watch_balance": False, } @@ -171,10 +171,10 @@ def __init__( self.positions_held: dict[str, Decimal] = {} self.locked = ZERO self._n = 0 + # Fills made but not yet delivered. A real venue reports a fill on a + # stream, after the call that caused it has returned, and code that + # reacts to fills must work that way here too. self.pending: list[Fill] = [] - """Fills made but not yet delivered. A real venue reports a fill on a - stream, after the call that caused it has returned, and code that - reacts to fills must work that way here too.""" self.listeners: list[Callable[[Fill], Any]] = [] self.order_listeners: list[Callable[[Order], Any]] = [] self.pending_orders: list[Order] = [] @@ -260,7 +260,7 @@ async def create_order(self, request: OrderRequest) -> Order: type=request.type, time_in_force=request.time_in_force, status=OrderStatus.OPEN, price=price, quantity=request.quantity, filled=ZERO, remaining=request.quantity, book=request.book, trader=request.trader, created_at=int(self.clock() * 1000), tags=dict(request.tags), expires_at=request.expires_at, - post_only=request.post_only, reduce_only=request.reduce_only, + post_only=request.post_only, outcome=request.outcome, action=request.action, ) self.orders[order_id] = order rest = Resting(order=order, queue_ahead=book.size_at(request.side, price) if price is not None else ZERO, request=request) @@ -532,3 +532,450 @@ def equity(self) -> Decimal: if price is not None: total += price * contracts return total + + +# -- RFQ and combos on paper --------------------------------------------------------- + + +@dataclass +class PaperMaker: + """A counterparty that answers every RFQ a `PaperRfqVenue` receives.""" + + name: str = "maker" + # The YES price it buys at (what a requester selling YES gets). + bid: Decimal | None = Decimal("0.40") + # The YES price it sells at (what a requester buying YES pays). + ask: Decimal | None = Decimal("0.45") + # Contracts on each side; `None` sizes to the RFQ (its amount at the price). + quantity: Decimal | None = None + # `False` lets the last look lapse: the accepted quote expires. + confirms: bool = True + # How much of the accepted size the requester's order fills; the rest + # is cancelled, as an IOC order's would be. + fill_ratio: Decimal = ONE + # Report this side as the one accepted, whatever was asked (the venue + # getting the side wrong, for the engine's side guard). + report_side: Side | None = None + # The quote's own expiry, seconds after it is made (`expires_at`, as + # Polymarket global gives one); `None` gives none (Kalshi, Polymarket US). + ttl_s: float | None = None + # The quote arrives this long after the RFQ (seen on the next read past it). + delay_s: float = 0.0 + + +class PaperRfqVenue(PaperVenue): + """A paper venue with RFQs, quotes and combos, both sides, offline. + + Requester side: `create_rfq` gets a quote from every configured + `PaperMaker`; `accept_quote` takes one; `advance()` runs the venue's + clock one step (the maker's last look, then the execution, which makes + an `rfq_taker` order and its fills). Quoter side: `add_incoming_rfq` + is someone else's RFQ, `requester_accepts` is that someone taking our + quote, `confirm_quote` our last look, `advance()` the execution. + + `auto_advance` steps a quote each time it is read, so a follower (or + `wait=True`) sees the whole lifecycle without a driver. + """ + + id = "paper_rfq" + name = "Paper trading with RFQ" + has: dict[str, Capability] = {**PaperVenue.has, "rfq_taker": True, "rfq_quoter": True, "combos": True} + + def __init__(self, *, makers: Sequence[PaperMaker] = (), last_look_s: float = 3.0, + auto_advance: bool = False, maker_fill_ratio: Decimal = ONE, stream_incoming: bool = False, + rfq_ttl_s: float | None = None, **kw: Any): + super().__init__(**kw) + # Our RFQs' expiry, seconds after the create (`Rfq.expires_at`); the + # venue closes one as `expired` once past it. `None`: no expiry. + self.rfq_ttl_s = rfq_ttl_s + self._delayed: list[tuple[int, Any, Any]] = [] + # `True` pushes each `add_incoming_rfq` to `subscribe_incoming_rfqs` + # listeners at once, as a venue stream would; `False` leaves it to be polled. + self.stream_incoming = stream_incoming + self.rfq_listeners: list[Callable[[Any], Any]] = [] + self.stream_started = False + self.makers: list[PaperMaker] = list(makers) or [PaperMaker()] + self.last_look_s = last_look_s + self.auto_advance = auto_advance + # How much of a confirmed maker quote fills, on the quoter side. + self.maker_fill_ratio = maker_fill_ratio + self.rfqs: dict[str, Any] = {} + self.quotes: dict[str, Any] = {} + self.combos: dict[str, Any] = {} + self.accepted: dict[str, dict[str, Any]] = {} + self.calls: list[str] = [] + # The `collection_id` each `create_combo` was given. + self.combo_collections: list[str | None] = [] + self.quote_listeners: list[Callable[[Any], Any]] = [] + self.pending_quotes: list[Any] = [] + self._q = 0 + + # -- streams -------------------------------------------------------------- + + def subscribe_incoming_rfqs(self, listener: Callable[[Any], Any]) -> Callable[[], None] | None: + """With `stream_incoming`, hear each incoming RFQ as it is added.""" + if not self.stream_incoming: + return None + self.rfq_listeners.append(listener) + return lambda: self.rfq_listeners.remove(listener) if listener in self.rfq_listeners else None + + async def start_incoming_rfqs(self) -> None: + self.stream_started = True + + async def push_incoming_rfq(self, instrument_id: str, **kw: Any) -> Any: + """`add_incoming_rfq`, then every listener hears it (awaited), as a stream would deliver it.""" + rfq = self.add_incoming_rfq(instrument_id, **kw) + for listener in list(self.rfq_listeners): + result = listener(rfq) + if hasattr(result, "__await__"): + await result + return rfq + + def subscribe_quotes(self, listener: Callable[[Any], Any]) -> Callable[[Any], Any]: + """Receive quote updates, as a venue's RFQ stream would send them.""" + self.quote_listeners.append(listener) + return listener + + async def deliver(self) -> list[Fill]: + """Quote updates first (the execution), then fills and orders.""" + self._expire() + updates, self.pending_quotes = self.pending_quotes, [] + for quote in updates: + for listener in list(self.quote_listeners): + result = listener(self.quotes.get(quote.id, quote)) + if hasattr(result, "__await__"): + await result + return await super().deliver() + + def _set_quote(self, quote: Any) -> Any: + self.quotes[quote.id] = quote + self.pending_quotes = [q for q in self.pending_quotes if q.id != quote.id] + self.pending_quotes.append(quote) + return quote + + def _next(self, prefix: str) -> str: + self._q += 1 + return f"{prefix}-{self._q}" + + def _now(self) -> int: + return int(self.clock() * 1000) + + # -- combos --------------------------------------------------------------- + + async def create_combo(self, legs: list[Any], *, collection_id: str | None = None) -> Any: + import hashlib + + from ..trading.types import Combo + + self.calls.append("create_combo") + self.combo_collections.append(collection_id) + digest = hashlib.sha1("|".join(sorted(f"{l.market_id}={l.outcome}" for l in legs)).encode()).hexdigest()[:10] + market_id = f"{self.venue}:combo-{digest}" + combo = self.combos.get(market_id) or Combo(market_id=market_id, venue=self.venue, legs=list(legs), + collection_id=collection_id, status="open", + created_at=self._now()) + self.combos[market_id] = combo + return combo + + async def fetch_combo(self, combo_id: str) -> Any: + from ..trading.errors import RfqNotFound + + if combo_id not in self.combos: + raise RfqNotFound(f"paper: no combo {combo_id}") + return self.combos[combo_id] + + # -- requester side ------------------------------------------------------- + + async def create_rfq(self, instrument_id: str, *, quantity: Decimal | None = None, amount: Decimal | None = None, + outcome: str | None = None, action: str | None = None, rest_remainder: bool = False, + combo: Any = None) -> Any: + from ..trading.types import Rfq + + self.calls.append("create_rfq") + on_combo = combo is not None or instrument_id in self.combos + rfq = Rfq(id=self._next("prfq"), venue=self.venue, account=self.account, + market_id=None if on_combo else instrument_id, combo_id=instrument_id if on_combo else None, + combo=combo or self.combos.get(instrument_id), quantity=quantity, amount=amount, + rest_remainder=rest_remainder, mine=True, created_at=self._now(), + expires_at=self._now() + int(self.rfq_ttl_s * 1000) if self.rfq_ttl_s is not None else None) + self.rfqs[rfq.id] = rfq + for maker in self.makers: + if maker.delay_s > 0: + self._delayed.append((self._now() + int(maker.delay_s * 1000), rfq.id, maker)) + else: + self._maker_quote(rfq, maker) + return rfq + + def _expire(self) -> None: + """The venue's clock on the requester side: delayed quotes arrive, and + quotes and RFQs past their `expires_at` expire.""" + from ..trading.types import QuoteStatus, RfqCloseReason, RfqStatus + + now = self._now() + due = [d for d in self._delayed if d[0] <= now] + self._delayed = [d for d in self._delayed if d[0] > now] + for _, rfq_id, maker in due: + rfq = self.rfqs.get(rfq_id) + if rfq is not None and rfq.status == RfqStatus.OPEN: + self._maker_quote(rfq, maker) + for quote in list(self.quotes.values()): + if quote.status == QuoteStatus.OPEN and not quote.mine and quote.expires_at is not None \ + and quote.expires_at <= now: + self._set_quote(quote.model_copy(update={"status": QuoteStatus.EXPIRED, "updated_at": now})) + for rfq in list(self.rfqs.values()): + if rfq.mine and rfq.status == RfqStatus.OPEN and rfq.expires_at is not None and rfq.expires_at <= now: + self.rfqs[rfq.id] = rfq.model_copy(update={"status": RfqStatus.CLOSED, + "closed_reason": RfqCloseReason.EXPIRED}) + for quote in list(self.quotes.values()): + if quote.rfq_id == rfq.id and quote.status == QuoteStatus.OPEN: + self._set_quote(quote.model_copy(update={"status": QuoteStatus.EXPIRED, "updated_at": now})) + + def _maker_quote(self, rfq: Any, maker: PaperMaker) -> Any: + from ..trading.types import RfqQuote + + def size(price: Decimal | None, cost: Decimal | None) -> Decimal | None: + if price is None: + return None + if maker.quantity is not None: + return maker.quantity + if rfq.quantity is not None: + return rfq.quantity + return (rfq.amount / cost).quantize(Decimal("0.01"), rounding="ROUND_DOWN") if cost else None + + quote = RfqQuote( + id=self._next("pq"), rfq_id=rfq.id, venue=self.venue, market_id=rfq.instrument_id, + bid=maker.bid, ask=maker.ask, + bid_quantity=size(maker.bid, ONE - maker.bid if maker.bid is not None else None), + ask_quantity=size(maker.ask, maker.ask), created_at=self._now(), mine=False, + expires_at=self._now() + int(maker.ttl_s * 1000) if maker.ttl_s is not None else None, + info={"maker": maker.name}, + ) + return self._set_quote(quote) + + def _maker_of(self, quote: Any) -> PaperMaker | None: + return next((m for m in self.makers if m.name == quote.info.get("maker")), None) + + async def fetch_rfq(self, rfq_id: str) -> Any: + from ..trading.errors import RfqNotFound + + self._expire() + if rfq_id not in self.rfqs: + raise RfqNotFound(f"paper: no RFQ {rfq_id}") + return self.rfqs[rfq_id] + + async def fetch_rfqs(self, *, status: Any = None, market_id: str | None = None, limit: int | None = None, + cursor: str | None = None) -> Any: + from ..types import Page + + rows = [r for r in self.rfqs.values() if r.mine + and (status is None or r.status.value == getattr(status, "value", status)) + and (market_id is None or r.instrument_id == market_id)] + return Page(rows[: limit or len(rows)]) + + async def cancel_rfq(self, rfq_id: str) -> None: + from ..trading.types import QuoteStatus, RfqCloseReason, RfqStatus + + self.calls.append("cancel_rfq") + rfq = await self.fetch_rfq(rfq_id) + self.rfqs[rfq_id] = rfq.model_copy(update={"status": RfqStatus.CLOSED, "closed_reason": RfqCloseReason.CANCELED}) + for quote in list(self.quotes.values()): + if quote.rfq_id == rfq_id and quote.status == QuoteStatus.OPEN: + self._set_quote(quote.model_copy(update={"status": QuoteStatus.CANCELED})) + + async def fetch_quotes(self, rfq_id: str, *, status: Any = None) -> list[Any]: + self._expire() + return [q for q in self.quotes.values() if q.rfq_id == rfq_id + and (status is None or q.status.value == getattr(status, "value", status))] + + async def fetch_quote(self, quote_id: str, *, rfq_id: str | None = None) -> Any: + from ..trading.errors import RfqNotFound + + self._expire() + if quote_id not in self.quotes: + raise RfqNotFound(f"paper: no quote {quote_id}") + if self.auto_advance: + self._step(quote_id) + return self.quotes[quote_id] + + async def accept_quote(self, accept: Any) -> Any: + from ..trading.types import QuoteStatus + + self.calls.append("accept_quote") + self._expire() + quote = self.quotes.get(accept.quote_id) + if quote is None: + from ..trading.errors import RfqNotFound + raise RfqNotFound(f"paper: no quote {accept.quote_id}") + if quote.status != QuoteStatus.OPEN: + from ..trading.errors import OrderRejected + raise OrderRejected(f"paper: quote {quote.id} is {quote.status.value}", reason="quote_not_open") + price = quote.price_for(accept.outcome, accept.action) + if price is None: + raise InvalidOrder(f"paper: quote {quote.id} declines that side") + if accept.refuses(price): + raise InvalidOrder(f"paper: {price} is worse than {accept.worst_price}") + if accept.amount is not None: + raise InvalidOrder("paper: accept by contracts; the engine sizes an amount") + full = quote.quantity_for(accept.outcome, accept.action) + quantity = accept.quantity or full + if quantity is None or (full is not None and quantity > full): + raise InvalidOrder(f"paper: cannot take {quantity} of quote {quote.id}") + maker = self._maker_of(quote) + reported = maker.report_side if maker is not None and maker.report_side is not None else accept.side + self.accepted[quote.id] = {"accept": accept, "quantity": quantity, "price": price, "side": reported} + return self._set_quote(quote.model_copy(update={ + "status": QuoteStatus.ACCEPTED, "accepted_side": reported, + "confirm_by": self._now() + int(self.last_look_s * 1000), "updated_at": self._now(), + })) + + # -- quoter side ---------------------------------------------------------- + + def add_incoming_rfq(self, instrument_id: str, *, quantity: Decimal | None = None, amount: Decimal | None = None, + combo: Any = None) -> Any: + """Someone else's RFQ, for us to quote.""" + from ..trading.types import Rfq + + on_combo = combo is not None or instrument_id in self.combos + rfq = Rfq(id=self._next("prfq-in"), venue=self.venue, market_id=None if on_combo else instrument_id, + combo_id=instrument_id if on_combo else None, combo=combo, quantity=quantity, amount=amount, + mine=False, created_at=self._now()) + self.rfqs[rfq.id] = rfq + return rfq + + async def fetch_incoming_rfqs(self, *, market_id: str | None = None, limit: int | None = None, + cursor: str | None = None) -> Any: + from ..trading.types import RfqStatus + from ..types import Page + + rows = [r for r in self.rfqs.values() if not r.mine and r.status == RfqStatus.OPEN + and (market_id is None or r.instrument_id == market_id)] + return Page(rows[: limit or len(rows)]) + + async def send_quote(self, quote: Any) -> Any: + from ..trading.types import RfqQuote + + self.calls.append("send_quote") + rfq = await self.fetch_rfq(quote.rfq_id) + quantity = quote.quantity or rfq.quantity + made = RfqQuote(id=self._next("pq-mine"), rfq_id=rfq.id, venue=self.venue, account=self.account, + market_id=rfq.instrument_id, bid=quote.bid, ask=quote.ask, + bid_quantity=quantity if quote.bid is not None else None, + ask_quantity=quantity if quote.ask is not None else None, + expires_at=quote.expires_at, mine=True, created_at=self._now(), + info={"client_order_id": quote.client_order_id}) + return self._set_quote(made) + + async def replace_quote(self, quote_id: str, quote: Any) -> Any: + from ..trading.types import QuoteStatus + + self.calls.append("replace_quote") + old = self.quotes.get(quote_id) + if old is not None and not old.is_terminal: + self._set_quote(old.model_copy(update={"status": QuoteStatus.CANCELED})) + return await self.send_quote(quote) + + async def cancel_quote(self, quote_id: str, *, rfq_id: str | None = None) -> None: + from ..trading.types import QuoteStatus + + self.calls.append("cancel_quote") + quote = self.quotes.get(quote_id) + if quote is None: + from ..trading.errors import RfqNotFound + raise RfqNotFound(f"paper: no quote {quote_id}") + if quote.status in (QuoteStatus.OPEN, QuoteStatus.ACCEPTED): + self._set_quote(quote.model_copy(update={"status": QuoteStatus.CANCELED})) + + def requester_accepts(self, quote_id: str, *, outcome: str = "yes", action: str = "buy", + quantity: Decimal | None = None) -> Any: + """Someone takes one side of our quote; our last look starts.""" + from ..trading.types import QuoteStatus, yes_side + + quote = self.quotes[quote_id] + side = yes_side(outcome, action) + price = quote.ask if side == Side.BUY else quote.bid + self.accepted[quote_id] = {"quantity": quantity or quote.quantity_for(outcome, action), "price": price, + "side": side} + return self._set_quote(quote.model_copy(update={ + "status": QuoteStatus.ACCEPTED, "accepted_side": side, + "confirm_by": self._now() + int(self.last_look_s * 1000), "updated_at": self._now(), + })) + + async def confirm_quote(self, quote: Any) -> Any: + from ..trading.types import QuoteStatus + + self.calls.append("confirm_quote") + current = self.quotes[quote.id] + if current.status != QuoteStatus.ACCEPTED: + from ..trading.errors import OrderRejected + raise OrderRejected(f"paper: quote {quote.id} is {current.status.value}", reason="quote_not_accepted") + return self._set_quote(current.model_copy(update={"status": QuoteStatus.CONFIRMED, "updated_at": self._now()})) + + async def fetch_my_quotes(self, *, rfq_id: str | None = None, status: Any = None) -> list[Any]: + return [q for q in self.quotes.values() if q.mine and (rfq_id is None or q.rfq_id == rfq_id) + and (status is None or q.status.value == getattr(status, "value", status))] + + # -- the venue's clock ---------------------------------------------------- + + def advance(self) -> None: + """One step for every quote in flight: an accepted quote is confirmed + by its maker (or lapses), a confirmed one executes.""" + for quote_id in list(self.accepted): + self._step(quote_id) + + def _step(self, quote_id: str) -> None: + from ..trading.types import QuoteStatus + + quote = self.quotes[quote_id] + if quote_id not in self.accepted: + return + if quote.status == QuoteStatus.ACCEPTED: + if quote.mine: + if quote.confirm_by is not None and self._now() >= quote.confirm_by: + self._set_quote(quote.model_copy(update={"status": QuoteStatus.EXPIRED})) + self.accepted.pop(quote_id, None) + return + maker = self._maker_of(quote) + if maker is not None and not maker.confirms: + self._set_quote(quote.model_copy(update={"status": QuoteStatus.EXPIRED})) + self.accepted.pop(quote_id, None) + else: + self._set_quote(quote.model_copy(update={"status": QuoteStatus.CONFIRMED})) + elif quote.status == QuoteStatus.CONFIRMED: + self._execute(quote) + + def _execute(self, quote: Any) -> None: + from ..trading.types import QuoteStatus, RfqCloseReason, RfqStatus + + deal = self.accepted.pop(quote.id) + requester_side: Side = deal["side"] + mine = quote.mine + side = requester_side if not mine else (Side.SELL if requester_side == Side.BUY else Side.BUY) + ratio = self.maker_fill_ratio if mine else (self._maker_of(quote) or PaperMaker()).fill_ratio + accept = deal.get("accept") + client_id = accept.client_order_id if accept is not None else quote.info.get("client_order_id") + outcome = accept.outcome if accept is not None else None + action = accept.action if accept is not None else None + order_id = self._next("paper-rfq") + quantity: Decimal = deal["quantity"] + order = Order( + id=order_id, client_order_id=client_id, venue=self.venue, account=self.account, + market_id=quote.market_id, side=side, type=OrderType.RFQ_MAKER if mine else OrderType.RFQ_TAKER, + time_in_force=TimeInForce.IOC, status=OrderStatus.OPEN, price=deal["price"], quantity=quantity, + created_at=self._now(), outcome=outcome, action=action, + ) + self.orders[order_id] = order + request = OrderRequest(market_id=quote.market_id, side=side, quantity=quantity, price=deal["price"], + type=OrderType.LIMIT, client_order_id=client_id) + rest = Resting(order=order, queue_ahead=ZERO, request=request) + filled = (quantity * ratio).quantize(Decimal("0.01"), rounding="ROUND_DOWN") + if filled > 0: + self._fill(rest, price=deal["price"], quantity=filled, liquidity=Liquidity.MAKER if mine else Liquidity.TAKER) + if self.orders[order_id].remaining and self.orders[order_id].remaining > 0: + self._close(order_id, OrderStatus.CANCELED) + update: dict[str, Any] = {"status": QuoteStatus.EXECUTED, "updated_at": self._now()} + update["maker_order_id" if mine else "taker_order_id"] = order_id + self._set_quote(quote.model_copy(update=update)) + rfq = self.rfqs.get(quote.rfq_id) + if rfq is not None: + self.rfqs[rfq.id] = rfq.model_copy(update={"status": RfqStatus.CLOSED, + "closed_reason": RfqCloseReason.EXECUTED}) diff --git a/synpath/engine/postgres.py b/synpath/engine/postgres.py new file mode 100644 index 0000000..4399e83 --- /dev/null +++ b/synpath/engine/postgres.py @@ -0,0 +1,157 @@ +"""PostgreSQL transport and lease for the shared journal operations. + +No SQLite file is used. A connection serializes whole transactions, including +reads from other tasks, so a task never observes another task's partial write. +""" +from __future__ import annotations + +import asyncio +import re +import socket +from typing import Any + +from .journal import Journal, SCHEMA, SCHEMA_VERSION, _dump +from .order_storage import OrderRecords, schema as order_schema +from .storage_types import Lease, LeaseLost + +SERVER_MS = "(extract(epoch from clock_timestamp()) * 1000)::bigint" + + +def postgres_schema() -> str: + """The same logical columns; PostgreSQL identities and 64-bit timestamps.""" + source = SCHEMA + order_schema() + source = source.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "BIGSERIAL PRIMARY KEY") + return re.sub(r"\bINTEGER\b", "BIGINT", source) + + +def postgres_query(query: str) -> str: + """Compile the journal's fixed SQL dialect, never interpolating values.""" + if query == "BEGIN IMMEDIATE": + return "BEGIN" + query = query.replace("json_extract(snapshot, '$.kind')", "(snapshot::jsonb ->> 'kind')") + query = query.replace("?", "%s") + if query.startswith("INSERT INTO events(") and "RETURNING" not in query: + query += " RETURNING seq" + elif query.startswith("INSERT INTO config_versions(") and "RETURNING" not in query: + query += " RETURNING version" + return query + + +class PostgresJournal(Journal): + """Shared journal semantics on one explicitly selected PostgreSQL schema.""" + def __init__(self, dsn: str, *, schema: str = "public", **kwargs: Any): + if not dsn.startswith(("postgresql://", "postgres://")) and "dbname=" not in dsn: + raise ValueError("PostgreSQL storage requires an explicit connection string in journal_path") + if (not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", schema) or len(schema) > 63 + or schema.lower().startswith("pg_") or schema.lower() == "information_schema"): + raise ValueError("invalid PostgreSQL schema name") + # The display path is deliberately not the connection string. + super().__init__(f"postgresql:{schema}", **kwargs) + self._dsn, self.schema = dsn, schema + + async def open(self): + if self.is_open: + return self + self._db = await self._connect() + try: + await self._initialize_schema() + self._order_records = OrderRecords(self._db, user_id=self.user_id) + except BaseException: + await self._db.close() + self._db = None + raise + return self + + async def _connect(self): + try: + from .database import connect_postgres + return await connect_postgres(self._dsn) + except ImportError: + raise ImportError("PostgreSQL storage needs: pip install 'synpath[postgres]'") from None + except Exception: + raise ConnectionError("cannot connect to configured PostgreSQL journal") from None + + async def _initialize_schema(self): + await self._db.execute("SET synchronous_commit=on") + await self._db.execute("BEGIN IMMEDIATE") + try: + # Serialize first-start DDL even when two workers start together. + await self._db.execute("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", + (f"synpath-schema:{self.schema}",)) + await self._select_schema() + await self._apply_schema() + from .migration import upgrade + await upgrade(self._db, self.user_id) + await self._db.execute("COMMIT") + except BaseException: + await self._db.execute("ROLLBACK") + raise + + async def _select_schema(self): + await self._db.execute(f'CREATE SCHEMA IF NOT EXISTS "{self.schema}"') + await self._db.execute(f'SET search_path TO "{self.schema}", pg_catalog') + await self._db.execute("CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT NOT NULL)") + expected_versions = {"schema_version": str(SCHEMA_VERSION), "order_records_version": "1", "submissions_version": "1"} + async with self._db.execute("SELECT key,value FROM meta WHERE key IN ('schema_version','order_records_version','submissions_version')") as cursor: + versions = await cursor.fetchall() + for version in versions: + if (version["key"] == "schema_version" + and version["value"] not in {str(v) for v in range(6, SCHEMA_VERSION + 1)}) or (version["key"] != "schema_version" and version["value"] != expected_versions[version["key"]]): + label = version["key"].replace("_", " ") + raise ValueError(f"unsupported PostgreSQL journal {label}") + + async def _apply_schema(self): + for statement in postgres_schema().split(";"): + if statement.strip(): + await self._db.execute(statement) + await self._db.execute("CREATE INDEX IF NOT EXISTS orders_parent ON orders(parent_id)") + for key, value in (("schema_version", "6"), + ("order_records_version", "1"), ("submissions_version", "1")): + await self._db.execute( + "INSERT INTO meta(key,value) VALUES(?,?) ON CONFLICT(key) DO NOTHING", + (key, value), + ) + + async def acquire_lease(self, name="engine", *, ttl_ms=30_000, steal_expired=True): + if ttl_ms <= 0: + raise ValueError("lease duration must be positive") + async with self._write: + await self._db.execute("BEGIN IMMEDIATE") + try: + async with self._db.execute(f""" + INSERT INTO leases(name,owner,holder,acquired_ts,expires_ts) + VALUES(?,?,?,{SERVER_MS},{SERVER_MS}+?) + ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,holder=excluded.holder, + acquired_ts=excluded.acquired_ts,expires_ts=excluded.expires_ts + WHERE leases.owner=? OR (? AND leases.expires_ts <= {SERVER_MS}) + RETURNING acquired_ts,expires_ts + """, (name, self.owner, socket.gethostname(), ttl_ms, self.owner, steal_expired)) as cursor: + row = await cursor.fetchone() + if row is None: + raise LeaseLost("PostgreSQL journal already has an active execution worker") + await self._db.execute("INSERT INTO events(ts,kind,key,payload) VALUES(?,?,?,?)", + (row["acquired_ts"], "lease.acquired", name, _dump({"owner": self.owner, "ttl_ms": ttl_ms}))) + await self._db.execute("COMMIT") + except BaseException: + await self._db.execute("ROLLBACK") + raise + self.lease = Lease(name, self.owner, socket.gethostname(), row["acquired_ts"], row["expires_ts"], ttl_ms) + return self.lease + + async def renew_lease(self): + if self.lease is None: + return False + async with self._write: + async with self._db.execute(f"UPDATE leases SET expires_ts={SERVER_MS}+? WHERE name=? AND owner=? AND expires_ts>{SERVER_MS} RETURNING expires_ts", + (self.lease.ttl_ms, self.lease.name, self.owner)) as cursor: + row = await cursor.fetchone() + if row is None: + return False + self.lease.expires_ts = row["expires_ts"] + return True + + async def holds_lease(self): + if self.lease is None: + return False + async with self._db.execute(f"SELECT 1 FROM leases WHERE name=? AND owner=? AND expires_ts>{SERVER_MS}", (self.lease.name, self.owner)) as cursor: + return await cursor.fetchone() is not None diff --git a/synpath/engine/reconcile.py b/synpath/engine/reconcile.py index 0bdbaac..8b5eb97 100644 --- a/synpath/engine/reconcile.py +++ b/synpath/engine/reconcile.py @@ -114,6 +114,7 @@ async def run(self, venue: str | None = None) -> list[ReconcileReport]: async def venue(self, venue: str, adapter: TradingExchange) -> ReconcileReport: report = ReconcileReport(venue=venue) + await self.engine.guard_routed_venue_accounts(venue) await self._orders(venue, adapter, report) await self._fills(venue, adapter, report) await self._positions(venue, adapter, report) diff --git a/synpath/engine/rfq.py b/synpath/engine/rfq.py new file mode 100644 index 0000000..da498da --- /dev/null +++ b/synpath/engine/rfq.py @@ -0,0 +1,1489 @@ +"""RFQ and combos in the engine: both sides, journaled, recoverable. + +`docs/rfq-combo.md` §5 is the design; this is the code behind +`engine.rfq` and the `Engine` methods that delegate to it. + +**An accept is an order.** Taking a side of a quote commits capital the way +an order does, so it takes the same road: the quote is re-read, the price +for the caller's outcome and action is checked against `worst_price`, an +`amount` is sized into contracts (`synpath.trading.money`), and the +resulting `rfq_taker` `OrderRequest` goes through `Engine.evaluate_order_risk`, every +rule an order meets. Only then is the intent written, keyed +`rfq-accept:{venue}:{quote_id}`, marked `sending`, and the venue called. +A confirm on the quoter side is the same with `operation="confirm_quote"` +and the side that would trade (an `rfq_maker` order). + +**The intent stays `sending` until the quote is final.** The venue answers +an accept with a quote in `accepted`; the maker's last look and the +execution come later. The engine does not invent a state for that: the +intent is in doubt until the quote is `executed` (the order is adopted and +the intent becomes `sent`) or ends without trading (`failed`). That one +rule makes following and recovering the same code: the follower loop, a +venue stream, `wait=True`, the sweep and `recover()` all call `resolve`, +which reads the quote back and acts on what the venue says. + +**What a restart finds.** An accept intent written but never sent (the +process died before the call) reads back as a quote still `open`: after +`lost_after_s` it is marked `rejected`, nothing traded. One sent whose +answer was lost reads back `accepted`, `confirmed` or `executed`: it is +followed, and adopted when it executes. A venue that cannot be reached +leaves it unresolved. + +**Book what the venue reports.** The order and its fills are booked on the +side the venue reports. Where that differs from the side the intent asked +for, `rfq.side_mismatch` is published (an alert) and the order's outcome +and action are left unset rather than contradict its side. + +**A combo is an instrument.** Its own market id carries the order, the +fills, the position, the P&L and the reconciliation. Its legs are kept in +the journal as `combo.saved` events and shown on the position +(`Position.info["combo"]`). No schema change. + +**Offers commit nothing.** Creating an RFQ and sending a maker quote are +journaled and checked against the halt, a paused book, the restricted list +and the order size limits (`RiskEngine.check_offer`), not the position +rules, which apply when the offer trades. A maker quote's `expires_at` is +held by the engine, which cancels the quote when it passes. A halt with the +`cancel` policy cancels every open RFQ we asked and every quote we made, on +every venue, before it cancels orders. +""" +from __future__ import annotations + +import asyncio +import inspect +import json +import logging +import time +import uuid +from decimal import Decimal +from typing import TYPE_CHECKING, Any, Callable + +from .. import ids +from ..errors import BadRequest, ExchangeError, NetworkError +from ..trading.base import TradingExchange +from ..trading.errors import OrderNotFound, OrderRejected, RfqNotFound, RiskRejected +from ..trading.instruments import venue_precision +from ..trading.money import contracts_for_amount, outcome_price +from ..trading.types import ( + AcceptQuote, Account, Combo, ComboLeg, MakerQuote, Order, OrderRequest, OrderStatus, OrderType, Precision, + QuoteStatus, Rfq, RfqQuote, RfqStatus, Side, default_leg, yes_side, +) +from .storage_types import Intent, IntentState +from .submissions import SubmissionInDoubt + +if TYPE_CHECKING: + from .engine import Engine + +log = logging.getLogger("synpath.engine.rfq") + +ZERO = Decimal("0") +ONE = Decimal("1") + +# The cross-venue group's own intents (`synpath.engine.rfq_group`). +GROUP_OPERATIONS = frozenset({"rfq_group_create", "rfq_group_accept"}) +# Intent operations this module owns; `Engine._resolve` hands them here. +RFQ_OPERATIONS = frozenset({ + "create_rfq", "cancel_rfq", "accept_quote", "send_quote", "replace_quote", "cancel_quote", "confirm_quote", +}) | GROUP_OPERATIONS +# Operations whose intent stays `sending` until the quote is final. +FOLLOWED = frozenset({"accept_quote", "confirm_quote"}) + +ACCEPTED_MARK = "accepted at the venue" +CONFIRMED_MARK = "confirmed at the venue" + +HAPPY_PATH = (QuoteStatus.OPEN, QuoteStatus.ACCEPTED, QuoteStatus.CONFIRMED, QuoteStatus.EXECUTED) +STATUS_EVENT = { + QuoteStatus.OPEN: "quote.received", QuoteStatus.ACCEPTED: "quote.accepted", + QuoteStatus.CONFIRMED: "quote.confirmed", QuoteStatus.EXECUTED: "quote.executed", + QuoteStatus.CANCELED: "quote.canceled", QuoteStatus.EXPIRED: "quote.expired", + QuoteStatus.FAILED: "quote.failed", +} +NO_TRADE = (QuoteStatus.CANCELED, QuoteStatus.EXPIRED, QuoteStatus.FAILED) +RECENT_MS = 5_000 +# How often the follower re-reads one of our open RFQs from its venue, to +# see it close there (a venue's own expiry, often unstated, as Kalshi's); at +# once when the venue's `expires_at` for it has passed. +RFQ_STATUS_INTERVAL_S = 10.0 +"""How far before an in-doubt create an RFQ or quote may be stamped and still be its result.""" + + +def _opposite(side: Side) -> Side: + return Side.SELL if side == Side.BUY else Side.BUY + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +class RfqDesk: + """The engine's RFQ and combo desk, attached as `engine.rfq`.""" + + def __init__(self, engine: "Engine"): + self.engine = engine + # Our open RFQs, `venue:id` -> {rfq, book, trader}: watched for quotes. + self.rfqs: dict[str, dict[str, Any]] = {} + # Our live maker quotes, `venue:id` -> what the engine holds about them (expiry, book, size). + self.maker_quotes: dict[str, dict[str, Any]] = {} + # Open RFQs from others, as the incoming feed last saw them. + self.incoming: dict[str, Rfq] = {} + self.combos: dict[str, Combo] = {} + # `venue:quote_id` -> the key of the accept or confirm intent following it. + self.following: dict[str, str] = {} + self._status: dict[str, QuoteStatus] = {} + self._last_quote: dict[str, RfqQuote] = {} + self._subscribed: set[str] = set() + self._incoming_ts = 0.0 + self._rfq_read: dict[str, float] = {} + # Venues whose incoming RFQs arrive on a stream (`subscribe_incoming_rfqs`): + # not polled, and their quoting callbacks run as each RFQ lands. + self.pushed: set[str] = set() + self._incoming_subscribed: set[str] = set() + self._streams_started: set[str] = set() + self._incoming_callbacks: list[Callable[[Rfq], Any]] = [] + + # -- plumbing ------------------------------------------------------------- + + @property + def config(self) -> Any: + return self.engine.config + + def _venue_for(self, venue: str | None, *, account: Account | None = None, market_id: str | None = None) -> str: + if venue: + return venue + if account is not None: + return account.venue + if market_id: + found, _ = ids.split(market_id) + if found is not None and found in self.engine.adapters: + return found + if len(self.engine.adapters) == 1: + return next(iter(self.engine.adapters)) + raise BadRequest("name the venue: pass venue=, name an account, or use a Synpath id (venue:native)") + + def _adapter(self, venue: str, capability: str) -> TradingExchange: + adapter = self.engine._adapter(venue) + if not adapter.has.get(capability): + raise BadRequest(f"{venue}: no {capability} on this venue (has[{capability!r}] is False)") + return adapter + + def _account(self, venue: str, account: Account | None = None) -> Account: + return account or self.engine.accounts.get(venue) or Account(venue=venue) + + async def _lease(self) -> None: + if self.engine.config.require_lease and not await self.engine.journal.holds_lease(): + from .journal import LeaseLost + raise LeaseLost("this execution worker no longer holds the journal lease") + + async def _publish(self, kind: str, payload: dict[str, Any], *, key: str, persist: bool = True) -> None: + await self.engine.bus.publish(kind, payload, key=key, persist=persist) + + async def _refused(self, decision: Any, *, operation: str, market_id: str, book: str | None, + extra: dict[str, Any] | None = None) -> RiskRejected: + await self.engine.bus.publish("risk.rejected", { + "rule": decision.rule, "message": decision.message, "operation": operation, "market_id": market_id, + "book": book, "config_version": decision.config_version, **decision.detail, **(extra or {}), + }) + return RiskRejected(decision.message, rule=decision.rule) + + def _precision(self, venue: str, market_id: str) -> Precision: + spec = self.engine.market_master.find(market_id) + if spec is not None: + return spec.precision + try: + return venue_precision(venue) + except KeyError: + return Precision(tick=Decimal("0.01")) + + def _record(self, *, market_id: str, key: str, account: Account, type: OrderType, side: Side = Side.BUY, + quantity: Decimal | None = None, price: Decimal | None = None, book: str | None = None, + trader: str | None = None, tags: dict[str, str] | None = None, outcome: str | None = None, + action: str | None = None, params: dict[str, Any] | None = None) -> OrderRequest: + """The intent's request: what the journal keeps about the operation.""" + body: dict[str, Any] = dict( + user_id=self.engine.config.user_id, market_id=market_id, side=side, quantity=quantity or ZERO, + type=type, price=price, client_order_id=key, account=account, book=book, trader=trader, + tags=dict(tags or {}), params=params or {}, + ) + if outcome is not None: + body.update(outcome=outcome, action=action) + return OrderRequest(**body) + + async def _begin(self, request: OrderRequest, *, key: str, venue: str, account: Account, operation: str, + target: str | None) -> None: + """Write the intent (or reopen a failed one for a retry) and mark it `sending`.""" + journal = self.engine.journal + prior = await journal.intent(key) + if prior is None: + await journal.record_intent(request, client_order_id=key, venue=venue, account=account, + operation=operation, target_order_id=target) + elif prior.state in (IntentState.REJECTED, IntentState.FAILED, IntentState.LOST): + await journal.mark_intent(key, IntentState.PLANNED, detail="retried") + await journal.mark_intent(key, IntentState.SENDING, bump_attempt=True) + + async def _save_working_set(self, venue: str) -> None: + """The open RFQs and live maker quotes, per venue, in the cursors + table: what the engine watches after a restart.""" + rfqs = [v for k, v in self.rfqs.items() if k.startswith(f"{venue}:")] + quotes = [v for k, v in self.maker_quotes.items() if k.startswith(f"{venue}:")] + await self.engine.journal.set_cursor(f"rfq:open:{venue}", json.dumps(rfqs, default=str)) + await self.engine.journal.set_cursor(f"rfq:maker_quotes:{venue}", json.dumps(quotes, default=str)) + + # -- events --------------------------------------------------------------- + + def _quote_key(self, venue: str, quote_id: str) -> str: + return f"{venue}:quote:{quote_id}" + + async def _last_status(self, key: str) -> QuoteStatus | None: + if key in self._status: + return self._status[key] + for event in await self.engine.journal.events_page(prefixes=["quote."], keys=[key], limit=20): + status = event.payload.get("status") + if status in QuoteStatus._value2member_map_: + self._status[key] = QuoteStatus(status) + return self._status[key] + return None + + async def _observe(self, venue: str, quote: RfqQuote, *, extra: dict[str, Any] | None = None) -> None: + """Publish what changed about a quote since the engine last saw it. + A jump along the happy path (open to executed between two reads) + publishes the steps in between too, marked `inferred`.""" + key = self._quote_key(venue, quote.id) + self._last_quote[key] = quote + last = await self._last_status(key) + new = quote.status + if last == new: + return + steps: list[tuple[QuoteStatus, bool]] = [] + if new in HAPPY_PATH: + start = HAPPY_PATH.index(last) + 1 if last in HAPPY_PATH else (1 if last is None and new != QuoteStatus.OPEN else 0) + for step in HAPPY_PATH[start:HAPPY_PATH.index(new)]: + if step != QuoteStatus.OPEN: + steps.append((step, True)) + steps.append((new, False)) + body = quote.model_dump(mode="json") + for status, inferred in steps: + self._status[key] = status + payload = {**body, "status": status.value, **(extra or {})} + if inferred: + payload["inferred"] = True + await self._publish(STATUS_EVENT[status], payload, key=key) + await self._groups_hear(venue, quote) + + async def _groups_hear(self, venue: str, quote: RfqQuote) -> None: + """Hand a quote on one of our RFQs to the cross-venue groups, which + rank it if the RFQ is a group member. Never fails the caller.""" + groups = getattr(self.engine, "rfq_groups", None) + if groups is None or quote.mine: + return + try: + await groups.on_quote(venue, quote) + except Exception: + log.exception("synpath.engine: ranking quote %s at %s for its group failed", quote.id, venue) + + async def _guard_side(self, key: str, venue: str, *, expected: Side, reported: Side | None, + quote_id: str, where: str) -> bool: + """`True` when the venue reports the side asked for (or none).""" + if reported is None or reported == expected: + return True + found = await self.engine.journal.events_page(kinds=["rfq.side_mismatch"], keys=[key], limit=1) + if not found: + log.error("synpath.engine: %s reports %s on quote %s, the intent %s asked for %s; booking the venue's side", + venue, reported.value, quote_id, key, expected.value) + await self._publish("rfq.side_mismatch", { + "client_order_id": key, "venue": venue, "quote_id": quote_id, "expected": expected.value, + "reported": reported.value, "where": where, + }, key=key) + return False + + # -- combos --------------------------------------------------------------- + + async def create_combo(self, legs: list[ComboLeg], *, venue: str | None = None, + collection_id: str | None = None) -> Combo: + """The venue's combo for these legs (`create_combo` on its adapter), + kept with the engine. `collection_id` is passed only when given, so + an adapter without the keyword still works without it.""" + venue = self._venue_for(venue, market_id=legs[0].market_id if legs else None) + adapter = self._adapter(venue, "combos") + combo = await (adapter.create_combo(legs, collection_id=collection_id) if collection_id is not None + else adapter.create_combo(legs)) + await self.save_combo(combo) + return combo + + async def save_combo(self, combo: Combo) -> None: + """Keep a combo's legs with the engine (`combo.saved`), for display + on its position and fills. Written once per change.""" + known = self.combos.get(combo.market_id) + if known is not None and [l.model_dump() for l in known.legs] == [l.model_dump() for l in combo.legs]: + return + self.combos[combo.market_id] = combo + await self._publish("combo.saved", combo.model_dump(mode="json"), key=f"combo:{combo.market_id}") + + async def combo(self, market_id: str) -> Combo | None: + if market_id in self.combos: + return self.combos[market_id] + found = await self.engine.journal.events_page(kinds=["combo.saved"], keys=[f"combo:{market_id}"], limit=1) + if found: + self.combos[market_id] = Combo.model_validate(found[0].payload) + return self.combos[market_id] + return None + + # -- taker: RFQs ---------------------------------------------------------- + + async def create_rfq( + self, instrument_id: str, *, quantity: Decimal | None = None, amount: Decimal | None = None, + outcome: str | None = None, action: str | None = None, rest_remainder: bool = False, + combo: Combo | None = None, venue: str | None = None, account: Account | None = None, + book: str | None = None, trader: str | None = None, tags: dict[str, str] | None = None, + key: str | None = None, + ) -> Rfq: + """Ask for quotes on a market or a combo. Journaled; refused while + halted, on a paused book, a restricted market or over the size limits. + `key` names the intent (a cross-venue group names its members' so a + restart can find them); by default one is made.""" + await self._lease() + if (quantity is None) == (amount is None): + raise BadRequest("size an RFQ by quantity (contracts) or amount (money), exactly one") + venue = self._venue_for(venue, account=account, market_id=instrument_id) + adapter = self._adapter(venue, "rfq_taker") + account = self._account(venue, account) + decision = self.engine.order_risk_checks.check_offer(instrument_id, venue=venue, book=book, quantity=quantity, notional=amount) + if not decision.ok: + raise await self._refused(decision, operation="create_rfq", market_id=instrument_id, book=book) + if combo is not None: + await self.save_combo(combo) + key = key or f"rfq-create:{venue}:{uuid.uuid4().hex[:16]}" + request = self._record(market_id=instrument_id, key=key, account=account, type=OrderType.RFQ_TAKER, + quantity=quantity, book=book, trader=trader, tags=tags, + params={"amount": str(amount) if amount is not None else None, + "outcome": outcome, "action": action, "rest_remainder": rest_remainder, + "combo": combo.model_dump(mode="json") if combo else None}) + await self._begin(request, key=key, venue=venue, account=account, operation="create_rfq", target=None) + try: + rfq = await adapter.create_rfq(instrument_id, quantity=quantity, amount=amount, outcome=outcome, + action=action, rest_remainder=rest_remainder, combo=combo) + except (NetworkError, asyncio.TimeoutError) as exc: + await self._publish("rfq.in_doubt", {"client_order_id": key, "venue": venue, "error": str(exc)}, key=key) + raise SubmissionInDoubt(f"{venue}: no answer to the RFQ create ({type(exc).__name__}: {exc}); " + "the engine resolves it against the venue", client_order_id=key) from exc + except ExchangeError as exc: + await self.engine.journal.mark_intent(key, IntentState.REJECTED, detail=str(exc)) + raise + await self._track_rfq(venue, rfq, book=book, trader=trader, key=key) + return rfq + + async def _track_rfq(self, venue: str, rfq: Rfq, *, book: str | None, trader: str | None, key: str) -> None: + if rfq.account is None: + rfq = rfq.model_copy(update={"account": self._account(venue)}) + if rfq.combo is not None: + await self.save_combo(rfq.combo) + await self.engine.journal.mark_intent(key, IntentState.SENT, order_id=rfq.id) + self.rfqs[f"{venue}:{rfq.id}"] = {"rfq": rfq.model_dump(mode="json"), "book": book, "trader": trader} + await self._save_working_set(venue) + await self._publish("rfq.created", {**rfq.model_dump(mode="json"), "book": book, "trader": trader}, + key=f"{venue}:rfq:{rfq.id}") + + async def cancel_rfq(self, rfq_id: str, *, venue: str | None = None, reason: str = "canceled by the caller") -> None: + """Close one of our RFQs. Journaled with its own key; never risk-checked.""" + venue = self._venue_for(venue, market_id=self._rfq_market(rfq_id)) + adapter = self._adapter(venue, "rfq_taker") + key = f"rfq-cancel:{venue}:{rfq_id}" + async with self.engine._lock(key): + prior = await self.engine.journal.intent(key) + if prior is not None and prior.state == IntentState.SETTLED: + return + held = self.rfqs.get(f"{venue}:{rfq_id}") + market_id = held["rfq"].get("market_id") or held["rfq"].get("combo_id") if held else (self._rfq_market(rfq_id) or rfq_id) + account = self._account(venue) + request = self._record(market_id=market_id, key=key, account=account, type=OrderType.RFQ_TAKER, + book=held.get("book") if held else None, params={"rfq_id": rfq_id, "reason": reason}) + await self._begin(request, key=key, venue=venue, account=account, operation="cancel_rfq", target=rfq_id) + try: + await adapter.cancel_rfq(rfq_id) + except RfqNotFound: + pass + except (NetworkError, asyncio.TimeoutError) as exc: + raise SubmissionInDoubt(f"{venue}: no answer to the RFQ cancel ({exc})", client_order_id=key) from exc + await self._rfq_closed(venue, rfq_id, key=key, reason="canceled", detail=reason) + + async def _rfq_closed(self, venue: str, rfq_id: str, *, key: str | None, reason: str, detail: str = "") -> None: + if key is not None: + await self.engine.journal.mark_intent(key, IntentState.SETTLED, order_id=rfq_id, detail=detail or None) + held = self.rfqs.pop(f"{venue}:{rfq_id}", None) + self._rfq_read.pop(f"{venue}:{rfq_id}", None) + await self._save_working_set(venue) + if reason in ("canceled", "expired"): + status = QuoteStatus.CANCELED if reason == "canceled" else QuoteStatus.EXPIRED + for qkey, quote in list(self._last_quote.items()): + if qkey.startswith(f"{venue}:quote:") and quote.rfq_id == rfq_id and not quote.mine \ + and quote.status == QuoteStatus.OPEN: + await self._observe(venue, quote.model_copy(update={"status": status}), + extra={"reason": "rfq_closed"}) + await self._publish("rfq.closed", {"rfq_id": rfq_id, "venue": venue, "reason": reason, "detail": detail, + "book": held.get("book") if held else None}, key=f"{venue}:rfq:{rfq_id}") + groups = getattr(self.engine, "rfq_groups", None) + if groups is not None: + try: + await groups.on_rfq_closed(venue, rfq_id, reason=reason) + except Exception: + log.exception("synpath.engine: closing RFQ %s at %s in its group failed", rfq_id, venue) + + def _rfq_market(self, rfq_id: str) -> str | None: + for key, held in self.rfqs.items(): + if key.endswith(f":{rfq_id}"): + return held["rfq"].get("market_id") or held["rfq"].get("combo_id") + return None + + def _rfq_held(self, venue: str, rfq_id: str | None) -> dict[str, Any] | None: + return self.rfqs.get(f"{venue}:{rfq_id}") if rfq_id else None + + # -- taker: accept -------------------------------------------------------- + + async def accept_quote(self, accept: AcceptQuote, *, venue: str | None = None, wait: bool = False, + timeout_s: float = 30.0, _group: str | None = None) -> RfqQuote: + """Take one side of a quote. Returns at once with the quote as the + venue answered (`accepted`); the confirm, the execution, the order and + its fills arrive as events. `wait=True` follows the quote until it is + final or `timeout_s` passes and returns it as it then stands. + + A quote on an RFQ that belongs to a cross-venue group is refused + here: it is accepted through the group (`accept_group_quote`), which + is what keeps a group to one accept.""" + await self._lease() + held = None + if venue is None and accept.account is None: + for key_, value in self.rfqs.items(): + if accept.rfq_id and key_.endswith(f":{accept.rfq_id}"): + held, venue = value, key_.split(":", 1)[0] + venue = self._venue_for(venue, account=accept.account) + adapter = self._adapter(venue, "rfq_taker") + account = self._account(venue, accept.account) + held = held or self._rfq_held(venue, accept.rfq_id) + key = accept.client_order_id or f"rfq-accept:{venue}:{accept.quote_id}" + async with self.engine._lock(key): + prior = await self.engine.journal.intent(key) + if prior is not None and prior.state in (IntentState.SENDING, IntentState.SENT, IntentState.SETTLED): + # A retry: the venue is asked, never sent to twice. + if prior.state == IntentState.SENDING: + await self._resolve_followed(prior) + quote = self._last_quote.get(self._quote_key(venue, accept.quote_id)) \ + or await adapter.fetch_quote(accept.quote_id, rfq_id=accept.rfq_id) + result = quote + else: + result = await self._accept_once(accept, venue=venue, adapter=adapter, account=account, key=key, held=held, + group=_group) + if wait: + return await self.wait(key, venue=venue, timeout_s=timeout_s) + return result + + async def check_accept(self, accept: AcceptQuote, *, venue: str) -> OrderRequest: + """Everything an accept checks before anything is written or sent + (the quote re-read, its price against `worst_price`, the sizing, + every order risk rule), without writing or sending: the order it + would be. Raises as the accept itself would.""" + adapter = self._adapter(venue, "rfq_taker") + account = self._account(venue, accept.account) + held = self._rfq_held(venue, accept.rfq_id) + key = accept.client_order_id or f"rfq-accept:{venue}:{accept.quote_id}" + groups = getattr(self.engine, "rfq_groups", None) + group = groups.group_of(venue, accept.rfq_id) if groups is not None else None + _, request = await self._prepare_accept(accept, venue=venue, adapter=adapter, account=account, key=key, + held=held, group=group) + return request + + async def _prepare_accept(self, accept: AcceptQuote, *, venue: str, adapter: TradingExchange, account: Account, + key: str, held: dict[str, Any] | None, + group: str | None = None) -> tuple[tuple[RfqQuote, Decimal | None], OrderRequest]: + quote = await adapter.fetch_quote(accept.quote_id, rfq_id=accept.rfq_id) + groups = getattr(self.engine, "rfq_groups", None) + owner = groups.group_of(venue, quote.rfq_id) if groups is not None else None + if owner is not None and owner != group: + raise BadRequest(f"{venue}: quote {quote.id} is on RFQ {quote.rfq_id} of cross-venue group {owner}; " + "accept it through the group (accept_group_quote), which cancels the other venues") + if quote.status != QuoteStatus.OPEN: + raise OrderRejected(f"{venue}: quote {quote.id} is {quote.status.value}, not open", reason="quote_not_open") + price = quote.price_for(accept.outcome, accept.action) + if price is None: + raise BadRequest(f"{venue}: quote {quote.id} declines the side that {accept.action}s {accept.outcome}") + rfq = Rfq.model_validate(held["rfq"]) if held else None + market_id = quote.market_id or (rfq.instrument_id if rfq else None) + if not market_id: + raise BadRequest(f"{venue}: quote {quote.id} names no instrument and the engine does not hold its RFQ") + book = accept.book or (held.get("book") if held else None) + trader = accept.trader or (held.get("trader") if held else None) + if accept.refuses(price): + message = (f"quote {quote.id} {accept.side.value}s YES at {price}, worse than the worst price " + f"{accept.worst_price}") + await self.engine.bus.publish("risk.rejected", { + "rule": "worst_price", "message": message, "operation": "rfq_taker", "market_id": market_id, + "book": book, "quote_id": quote.id, "price": str(price), "worst_price": str(accept.worst_price), + "config_version": self.engine.order_risk_checks.config_version, + }) + raise RiskRejected(message, rule="worst_price") + quantity, send_size = self._accept_size(accept, quote, price, venue, market_id, rfq) + request = self._record( + market_id=market_id, key=key, account=account, type=OrderType.RFQ_TAKER, side=accept.side, + quantity=quantity, price=price, book=book, trader=trader, tags=accept.tags, + outcome=accept.outcome, action=accept.action, + params={"rfq_id": accept.rfq_id or quote.rfq_id, "quote_id": quote.id, + "worst_price": str(accept.worst_price) if accept.worst_price is not None else None, + "amount": str(accept.amount) if accept.amount is not None else None}, + ) + decision = self.engine.evaluate_order_risk(request, venue=venue, account=account, caller_supplied_order_id=True) + if not decision.ok: + raise await self._refused(decision, operation="rfq_taker", market_id=market_id, book=book, + extra={"quote_id": quote.id, "client_order_id": key}) + return (quote, send_size), request + + async def _accept_once(self, accept: AcceptQuote, *, venue: str, adapter: TradingExchange, account: Account, + key: str, held: dict[str, Any] | None, group: str | None = None) -> RfqQuote: + (quote, send_size), request = await self._prepare_accept(accept, venue=venue, adapter=adapter, + account=account, key=key, held=held, group=group) + book = request.book + await self._begin(request, key=key, venue=venue, account=account, operation="accept_quote", target=quote.id) + self.engine.order_risk_checks.record_sent(request) + self.following[self._quote_key(venue, quote.id)] = key + sent = accept.model_copy(update={"client_order_id": key, "account": account, + "rfq_id": accept.rfq_id or quote.rfq_id, "quantity": send_size, + "amount": None}) + try: + result = await adapter.accept_quote(sent) + except (NetworkError, asyncio.TimeoutError) as exc: + await self._publish("quote.in_doubt", {"client_order_id": key, "venue": venue, "quote_id": quote.id, + "error": f"{type(exc).__name__}: {exc}"}, key=key) + raise SubmissionInDoubt( + f"{venue}: no answer to the accept of quote {quote.id} ({type(exc).__name__}: {exc}); it may have " + f"been taken. Retry with client_order_id {key!r}: the venue is read first and nothing is sent twice", + client_order_id=key) from exc + except ExchangeError as exc: + state = IntentState.REJECTED if isinstance(exc, (OrderRejected, BadRequest)) else IntentState.FAILED + await self.engine.journal.mark_intent(key, state, detail=str(exc)) + self.following.pop(self._quote_key(venue, quote.id), None) + await self._publish("quote.failed", {**quote.model_dump(mode="json"), "client_order_id": key, + "reason": "accept_refused", "error": str(exc)}, + key=self._quote_key(venue, quote.id)) + raise + await self.engine.journal.mark_intent(key, IntentState.SENDING, detail=ACCEPTED_MARK) + await self._guard_side(key, venue, expected=accept.side, reported=result.accepted_side, + quote_id=quote.id, where="accept") + await self._observe(venue, result, extra={"client_order_id": key, "book": book}) + return result + + def _accept_size(self, accept: AcceptQuote, quote: RfqQuote, price: Decimal, venue: str, market_id: str, + rfq: Rfq | None) -> tuple[Decimal, Decimal | None]: + """The contracts the accept trades (for risk and the record), and the + size to send: `None` when it is the quote's full size.""" + full = quote.quantity_for(accept.outcome, accept.action) + token = outcome_price(price, accept.outcome) + worst = outcome_price(accept.worst_price, accept.outcome) if accept.worst_price is not None else None + precision = self._precision(venue, market_id) + if accept.quantity is not None: + quantity = accept.quantity + if full is not None and quantity > full: + raise BadRequest(f"{venue}: {quantity} contracts is more than quote {quote.id} offers ({full})") + elif accept.amount is not None: + if full is not None and accept.amount >= full * token: + quantity = full + else: + quantity = contracts_for_amount(accept.amount, token, precision, action=accept.action, worst_price=worst) + elif full is not None: + quantity = full + elif rfq is not None and rfq.quantity is not None: + quantity = rfq.quantity + elif rfq is not None and rfq.amount is not None: + quantity = contracts_for_amount(rfq.amount, token, precision, action=accept.action, worst_price=worst) + else: + raise BadRequest(f"{venue}: quote {quote.id} gives no size and the engine does not hold its RFQ; " + "give quantity or amount") + return quantity, (None if full is not None and quantity == full else quantity) + + # -- following ------------------------------------------------------------ + + async def resolve(self, intent: Intent) -> dict[str, Any]: + """Resolve an RFQ intent against the venue: `Engine._resolve` (the + sweep and `recover()`) hands every RFQ operation here.""" + base = {"client_order_id": intent.client_order_id, "venue": intent.venue, "operation": intent.operation} + adapter = self.engine.adapters.get(intent.venue) + if adapter is None: + await self.engine.bus.publish("intent.unresolved", base | {"reason": "no adapter for this venue"}) + return base | {"result": "unresolved", "reason": "no adapter"} + if intent.operation in FOLLOWED: + return await self._resolve_followed(intent) + if intent.operation in GROUP_OPERATIONS: + return await self.engine.rfq_groups.resolve(intent) + try: + return await self._resolve_offer(adapter, intent, base) + except Exception as exc: + await self.engine.bus.publish("intent.unresolved", base | {"reason": f"{type(exc).__name__}: {exc}"}) + return base | {"result": "unresolved", "reason": str(exc)} + + async def _resolve_followed(self, intent: Intent) -> dict[str, Any]: + base = {"client_order_id": intent.client_order_id, "venue": intent.venue, "operation": intent.operation} + async with self.engine._lock(f"rfq-follow:{intent.client_order_id}"): + current = await self.engine.journal.intent(intent.client_order_id) + if current is None or current.state != IntentState.SENDING: + return base | {"result": "adopted" if current and current.state == IntentState.SENT else "swept"} + adapter = self.engine.adapters.get(intent.venue) + request = OrderRequest.stored(current.request) + self.following[self._quote_key(current.venue, current.target_order_id or "")] = current.client_order_id + try: + quote = await adapter.fetch_quote(current.target_order_id, rfq_id=request.params.get("rfq_id")) + except RfqNotFound: + if current.age_ms < self.config.lost_after_s * 1000: + return base | {"result": "pending", "age_ms": current.age_ms} + await self._no_trade(current, request, reason="not_found", detail="the venue has no such quote") + return base | {"result": "swept"} + except Exception as exc: + await self.engine.bus.publish("intent.unresolved", base | {"reason": f"{type(exc).__name__}: {exc}"}) + return base | {"result": "unresolved", "reason": str(exc)} + return await self._advance(current, request, quote, base) + + async def _advance(self, intent: Intent, request: OrderRequest, quote: RfqQuote, + base: dict[str, Any]) -> dict[str, Any]: + venue, key = intent.venue, intent.client_order_id + taker = intent.operation == "accept_quote" + extra = {"client_order_id": key, "book": request.book} + if taker: + await self._guard_side(key, venue, expected=request.side, reported=quote.accepted_side, + quote_id=quote.id, where="quote") + await self._observe(venue, quote, extra=extra) + status = quote.status + if status == QuoteStatus.EXECUTED: + order_id = quote.taker_order_id if taker else quote.maker_order_id + if not order_id: + return base | {"result": "pending", "reason": "executed, no order id yet"} + adapter = self.engine.adapters[venue] + try: + order = await adapter.fetch_order(order_id) + except OrderNotFound: + return base | {"result": "pending", "reason": f"order {order_id} not listed yet"} + await self._adopt(intent, request, order) + await self.engine.journal.mark_intent(key, IntentState.SENT, order_id=order.id, detail="executed") + self.following.pop(self._quote_key(venue, quote.id), None) + if taker: + rfq_id = request.params.get("rfq_id") + if rfq_id and f"{venue}:{rfq_id}" in self.rfqs: + await self._rfq_closed(venue, rfq_id, key=None, reason="executed", detail=quote.id) + else: + self.maker_quotes.pop(f"{venue}:{quote.id}", None) + await self._save_working_set(venue) + await self.engine.bus.publish("intent.adopted", base | {"order_id": order.id, "status": order.status.value}) + return base | {"result": "adopted", "order_id": order.id} + if status in NO_TRADE: + await self._no_trade(intent, request, reason=status.value, detail=f"quote {status.value}: nothing traded", + quote=quote) + return base | {"result": "swept"} + lapsed = intent.age_ms >= self.config.lost_after_s * 1000 + if taker and status == QuoteStatus.OPEN and intent.detail != ACCEPTED_MARK and lapsed: + await self._no_trade(intent, request, reason="never_accepted", state=IntentState.REJECTED, + detail="the venue never took the accept; nothing traded", quote=quote) + return base | {"result": "swept"} + if not taker and status == QuoteStatus.ACCEPTED and intent.detail != CONFIRMED_MARK and lapsed: + await self._no_trade(intent, request, reason="never_confirmed", state=IntentState.REJECTED, + detail="the confirm never reached the venue; the last look lapses", quote=quote) + return base | {"result": "swept"} + return base | {"result": "pending", "status": status.value} + + async def _no_trade(self, intent: Intent, request: OrderRequest, *, reason: str, detail: str, + state: str = IntentState.FAILED, quote: RfqQuote | None = None) -> None: + await self.engine.journal.mark_intent(intent.client_order_id, state, detail=detail) + quote_id = intent.target_order_id or "" + self.following.pop(self._quote_key(intent.venue, quote_id), None) + payload = (quote.model_dump(mode="json") if quote else {"id": quote_id, "venue": intent.venue}) + await self._publish("quote.failed", {**payload, "client_order_id": intent.client_order_id, + "operation": intent.operation, "reason": reason, "detail": detail, + "book": request.book}, + key=self._quote_key(intent.venue, quote_id)) + if state == IntentState.REJECTED: + await self.engine.bus.publish("intent.swept", {"client_order_id": intent.client_order_id, + "venue": intent.venue, "operation": intent.operation, + "reason": detail}) + + async def _adopt(self, intent: Intent, request: OrderRequest, order: Order) -> None: + """Record the order an executed quote made through the normal order + path, on the side the venue reports, then book its fills.""" + engine = self.engine + stamp = request + if order.side != request.side: + await self._guard_side(intent.client_order_id, intent.venue, expected=request.side, reported=order.side, + quote_id=intent.target_order_id or "", where="order") + stamp = request.model_copy(update={"side": order.side, "outcome": None, "action": None}) + stamped = engine._stamp(order, stamp) + if stamped.outcome is not None and yes_side(stamped.outcome, stamped.action or "buy") != stamped.side: + # The venue's own outcome contradicts its side: keep the side. + stamped = stamped.model_copy(update={"outcome": None, "action": None}) + if stamped.client_order_id is None: + stamped = stamped.model_copy(update={"client_order_id": intent.client_order_id}) + stored = await engine.journal.order(order.venue, order.id) + if stored is not None and stored.filled > stamped.filled: + stamped = engine._stamp(stored, stamp) + stamped = stamped.model_copy(update={"tags": {**stamped.tags, "rfq_id": str(request.params.get("rfq_id") or ""), + "quote_id": intent.target_order_id or ""}}) + await engine.journal.upsert_order(stamped, event="order.accepted") + engine._remember(stamped) + await engine.bus.publish("order.accepted", stamped.model_dump(mode="json"), + key=f"{order.venue}:{order.id}", persist=False) + if stamped.status not in (OrderStatus.OPEN, OrderStatus.PENDING): + await engine.bus.publish(f"order.{stamped.status.value}", stamped.model_dump(mode="json"), + key=f"{order.venue}:{order.id}", persist=False) + await engine.managed_order_controller.on_order(stamped) + adapter = engine.adapters[order.venue] + if adapter.has.get("fetch_my_trades"): + try: + fills = await adapter.fetch_my_trades(market_id=order.market_id) + except Exception as exc: # the poll loop books them later + log.warning("synpath.engine: reading the fills of %s on %s failed: %s", order.id, order.venue, exc) + fills = [] + for fill in fills: + if fill.order_id == order.id: + await engine.on_fill(fill) + + async def claim_fill(self, fill: Any) -> Order | None: + """A fill for an order the journal does not know: if an accept or a + confirm is being followed on that venue, resolve it first, so the + fill is booked against its order (book, outcome and action) rather + than unattributed.""" + pending = [k for q, k in self.following.items() if q.startswith(f"{fill.venue}:")] + for key in pending: + intent = await self.engine.journal.intent(key) + if intent is not None and intent.state == IntentState.SENDING: + await self._resolve_followed(intent) + return await self.engine.journal.order(fill.venue, fill.order_id) + + async def wait(self, key: str, *, venue: str, timeout_s: float = 30.0) -> RfqQuote: + """Follow one accept or confirm until its quote is final, or until + `timeout_s`; return the quote as it then stands.""" + deadline = time.monotonic() + timeout_s + while True: + intent = await self.engine.journal.intent(key) + if intent is None: + raise BadRequest(f"no RFQ intent {key!r}") + if intent.state == IntentState.SENDING: + await self._resolve_followed(intent) + intent = await self.engine.journal.intent(key) + quote = self._last_quote.get(self._quote_key(venue, intent.target_order_id or "")) + if intent.state != IntentState.SENDING or time.monotonic() >= deadline: + if quote is None: + adapter = self.engine.adapters[venue] + quote = await adapter.fetch_quote(intent.target_order_id, + rfq_id=OrderRequest.stored(intent.request).params.get("rfq_id")) + return quote + await asyncio.sleep(self.config.rfq_poll_interval_s) + + async def on_quote(self, quote: RfqQuote, *, venue: str | None = None) -> None: + """A quote update from a venue stream (or anything else): publish what + changed and move the accept or confirm following it.""" + venue = venue or quote.venue + qkey = self._quote_key(venue, quote.id) + key = self.following.get(qkey) + if key is not None: + intent = await self.engine.journal.intent(key) + if intent is not None and intent.state == IntentState.SENDING: + async with self.engine._lock(f"rfq-follow:{key}"): + intent = await self.engine.journal.intent(key) + if intent is not None and intent.state == IntentState.SENDING: + base = {"client_order_id": key, "venue": venue, "operation": intent.operation} + await self._advance(intent, OrderRequest.stored(intent.request), quote, base) + return + if f"{venue}:{quote.rfq_id}" in self.rfqs or f"{venue}:{quote.id}" in self.maker_quotes: + await self._observe(venue, quote) + if quote.status == QuoteStatus.EXECUTED and f"{venue}:{quote.rfq_id}" in self.rfqs: + await self._rfq_closed(venue, quote.rfq_id, key=None, reason="executed", detail=quote.id) + if quote.mine and quote.is_terminal: + self.maker_quotes.pop(f"{venue}:{quote.id}", None) + await self._save_working_set(venue) + + # -- quoter --------------------------------------------------------------- + + async def _incoming_rfq(self, venue: str, adapter: TradingExchange, rfq_id: str) -> Rfq: + known = self.incoming.get(f"{venue}:{rfq_id}") + if known is not None: + return known + return await adapter.fetch_rfq(rfq_id) + + def _maker_size(self, quote: MakerQuote, rfq: Rfq) -> tuple[Decimal | None, Decimal | None]: + """The most contracts and the most money the quote could commit us + to, on its worse side: buying YES at the bid costs the bid, selling + YES at the ask costs (holding NO) one minus the ask.""" + sides = [] + if quote.bid is not None: + sides.append((quote.bid, quote.bid, ONE - quote.bid)) # requester sells YES: pays 1 - bid per contract + if quote.ask is not None: + sides.append((quote.ask, ONE - quote.ask, quote.ask)) # requester buys YES: pays ask + quantity = quote.quantity or rfq.quantity + worst_q: Decimal | None = None + worst_n: Decimal | None = None + for _, ours, theirs in sides: + q = quantity if quantity is not None else (rfq.amount / theirs if rfq.amount is not None and theirs > 0 else None) + if q is None: + continue + worst_q = q if worst_q is None else max(worst_q, q) + n = q * ours + worst_n = n if worst_n is None else max(worst_n, n) + return worst_q, worst_n + + async def send_quote(self, quote: MakerQuote, *, venue: str | None = None) -> RfqQuote: + """Answer an incoming RFQ. Journaled; checked against the halt, a + paused book, the restricted list and the size limits. The engine + holds `expires_at` and cancels the quote when it passes.""" + await self._lease() + venue = self._venue_for(venue, account=quote.account) + adapter = self._adapter(venue, "rfq_quoter") + account = self._account(venue, quote.account) + rfq = await self._incoming_rfq(venue, adapter, quote.rfq_id) + return await self._quote_once(quote, venue=venue, adapter=adapter, account=account, rfq=rfq, + operation="send_quote", replaces=None) + + async def replace_quote(self, quote_id: str, quote: MakerQuote, *, venue: str | None = None) -> RfqQuote: + """Swap a live quote for new prices; the result may carry a new id.""" + await self._lease() + held = self._maker_held(quote_id, venue) + venue = self._venue_for(venue or (held or {}).get("venue"), account=quote.account) + adapter = self._adapter(venue, "rfq_quoter") + account = self._account(venue, quote.account) + rfq = await self._incoming_rfq(venue, adapter, quote.rfq_id) + return await self._quote_once(quote, venue=venue, adapter=adapter, account=account, rfq=rfq, + operation="replace_quote", replaces=quote_id) + + def _maker_held(self, quote_id: str, venue: str | None) -> dict[str, Any] | None: + if venue is not None: + return self.maker_quotes.get(f"{venue}:{quote_id}") + for key, held in self.maker_quotes.items(): + if key.endswith(f":{quote_id}"): + return held + return None + + async def _quote_once(self, quote: MakerQuote, *, venue: str, adapter: TradingExchange, account: Account, + rfq: Rfq, operation: str, replaces: str | None) -> RfqQuote: + market_id = rfq.instrument_id + now = _now_ms() + if quote.expires_at is not None and quote.expires_at <= now: + raise BadRequest("expires_at is in the past") + quantity, notional = self._maker_size(quote, rfq) + decision = self.engine.order_risk_checks.check_offer(market_id, venue=venue, book=quote.book, quantity=quantity, + notional=notional) + if not decision.ok: + raise await self._refused(decision, operation="rfq_quoter", market_id=market_id, book=quote.book, + extra={"rfq_id": quote.rfq_id}) + prefix = "rfq-quote" if replaces is None else f"rfq-replace:{replaces}" + key = quote.client_order_id or f"{prefix}:{venue}:{uuid.uuid4().hex[:16]}" + request = self._record( + market_id=market_id, key=key, account=account, type=OrderType.RFQ_MAKER, + side=Side.BUY if quote.bid is not None else Side.SELL, quantity=quote.quantity or rfq.quantity, + book=quote.book, trader=quote.trader, tags=quote.tags, + params={"rfq_id": quote.rfq_id, "maker_quote": quote.model_dump(mode="json"), "replaces": replaces}, + ) + await self._begin(request, key=key, venue=venue, account=account, operation=operation, target=replaces) + sent = quote.model_copy(update={"expires_at": None, "client_order_id": key, "account": account}) + try: + result = (await adapter.send_quote(sent) if replaces is None else await adapter.replace_quote(replaces, sent)) + except (NetworkError, asyncio.TimeoutError) as exc: + await self._publish("quote.in_doubt", {"client_order_id": key, "venue": venue, "rfq_id": quote.rfq_id, + "error": f"{type(exc).__name__}: {exc}"}, key=key) + raise SubmissionInDoubt(f"{venue}: no answer to the quote send ({type(exc).__name__}: {exc}); the " + "engine resolves it against the venue", client_order_id=key) from exc + except ExchangeError as exc: + await self.engine.journal.mark_intent(key, IntentState.REJECTED, detail=str(exc)) + raise + tracked = await self._track_quote(venue, result, quote=quote, key=key, market_id=market_id, replaces=replaces) + if rfq.combo is not None: + # After the send: the legs are for display, and a quote has a + # window to meet (docs/rfq-combo.md, "Quoting in time"). + await self.save_combo(rfq.combo) + return tracked + + async def _track_quote(self, venue: str, result: RfqQuote, *, quote: MakerQuote, key: str, market_id: str, + replaces: str | None) -> RfqQuote: + result = result.model_copy(update={"expires_at": quote.expires_at, "mine": True, + "market_id": result.market_id or market_id}) + await self.engine.journal.mark_intent(key, IntentState.SENT, order_id=result.id) + if replaces is not None: + old = self.maker_quotes.pop(f"{venue}:{replaces}", None) + okey = self._quote_key(venue, replaces) + self._status[okey] = QuoteStatus.CANCELED + await self._publish("quote.replaced", {"replaced": replaces, "status": QuoteStatus.CANCELED.value, + "quote_id": result.id, "rfq_id": result.rfq_id, + "book": quote.book, "old": old}, key=okey) + self.maker_quotes[f"{venue}:{result.id}"] = { + "id": result.id, "rfq_id": result.rfq_id, "venue": venue, "market_id": result.market_id, + "expires_at": quote.expires_at, "book": quote.book, "trader": quote.trader, + "quantity": str(quote.quantity) if quote.quantity is not None else None, + "bid": str(quote.bid) if quote.bid is not None else None, + "ask": str(quote.ask) if quote.ask is not None else None, "client_order_id": key, + } + await self._save_working_set(venue) + qkey = self._quote_key(venue, result.id) + self._status[qkey] = QuoteStatus.OPEN + self._last_quote[qkey] = result + await self._publish("quote.sent", {**result.model_dump(mode="json"), "status": QuoteStatus.OPEN.value, + "client_order_id": key, "book": quote.book, "replaces": replaces}, key=qkey) + return result + + async def cancel_quote(self, quote_id: str, *, rfq_id: str | None = None, venue: str | None = None, + reason: str = "canceled by the caller") -> None: + """Withdraw one of our quotes (or decline it in the last look). + Journaled with its own key; never risk-checked.""" + held = self._maker_held(quote_id, venue) + venue = self._venue_for(venue or (held or {}).get("venue")) + adapter = self._adapter(venue, "rfq_quoter") + rfq_id = rfq_id or (held or {}).get("rfq_id") + key = f"rfq-quote-cancel:{venue}:{quote_id}" + async with self.engine._lock(key): + prior = await self.engine.journal.intent(key) + if prior is not None and prior.state == IntentState.SETTLED: + return + account = self._account(venue) + request = self._record(market_id=(held or {}).get("market_id") or rfq_id or quote_id, key=key, + account=account, type=OrderType.RFQ_MAKER, book=(held or {}).get("book"), + params={"rfq_id": rfq_id, "reason": reason}) + await self._begin(request, key=key, venue=venue, account=account, operation="cancel_quote", target=quote_id) + try: + await adapter.cancel_quote(quote_id, rfq_id=rfq_id) + except RfqNotFound: + pass + except (NetworkError, asyncio.TimeoutError) as exc: + raise SubmissionInDoubt(f"{venue}: no answer to the quote cancel ({exc})", client_order_id=key) from exc + await self.engine.journal.mark_intent(key, IntentState.SETTLED, order_id=quote_id, detail=reason) + await self._quote_gone(venue, quote_id, rfq_id=rfq_id, reason=reason, + status=QuoteStatus.EXPIRED if reason == "expired" else QuoteStatus.CANCELED) + + async def _quote_gone(self, venue: str, quote_id: str, *, rfq_id: str | None, reason: str, + status: QuoteStatus) -> None: + held = self.maker_quotes.pop(f"{venue}:{quote_id}", None) + await self._save_working_set(venue) + qkey = self._quote_key(venue, quote_id) + known = self._last_quote.get(qkey) + if known is not None: + await self._observe(venue, known.model_copy(update={"status": status}), + extra={"reason": reason, "book": (held or {}).get("book")}) + else: + self._status[qkey] = status + await self._publish(STATUS_EVENT[status], {"id": quote_id, "rfq_id": rfq_id, "venue": venue, + "status": status.value, "reason": reason, "mine": True}, key=qkey) + + async def confirm_quote(self, quote: RfqQuote | str, *, rfq_id: str | None = None, venue: str | None = None, + wait: bool = False, timeout_s: float = 30.0) -> RfqQuote: + """Confirm a quote of ours a requester accepted: the maker's last + look. Commits capital, so it runs the order risk rules on the side and + size that would trade (an `rfq_maker` order), is journaled + (`rfq-confirm:{venue}:{quote_id}`), and is followed to execution like + an accept. Refused while halted: the last look lapses.""" + await self._lease() + quote_id = quote if isinstance(quote, str) else quote.id + held = self._maker_held(quote_id, venue) + venue = self._venue_for(venue or (quote.venue if isinstance(quote, RfqQuote) else None) + or (held or {}).get("venue")) + adapter = self._adapter(venue, "rfq_quoter") + rfq_id = rfq_id or (quote.rfq_id if isinstance(quote, RfqQuote) else None) or (held or {}).get("rfq_id") + key = f"rfq-confirm:{venue}:{quote_id}" + async with self.engine._lock(key): + prior = await self.engine.journal.intent(key) + if prior is not None and prior.state in (IntentState.SENDING, IntentState.SENT, IntentState.SETTLED): + if prior.state == IntentState.SENDING: + await self._resolve_followed(prior) + result = self._last_quote.get(self._quote_key(venue, quote_id)) \ + or await adapter.fetch_quote(quote_id, rfq_id=rfq_id) + else: + result = await self._confirm_once(quote_id, rfq_id=rfq_id, venue=venue, adapter=adapter, + held=held, key=key) + if wait: + return await self.wait(key, venue=venue, timeout_s=timeout_s) + return result + + async def _confirm_once(self, quote_id: str, *, rfq_id: str | None, venue: str, adapter: TradingExchange, + held: dict[str, Any] | None, key: str) -> RfqQuote: + current = await adapter.fetch_quote(quote_id, rfq_id=rfq_id) + if current.status != QuoteStatus.ACCEPTED: + raise OrderRejected(f"{venue}: quote {quote_id} is {current.status.value}; only an accepted quote is " + "confirmed", reason="quote_not_accepted") + if current.accepted_side is None: + raise BadRequest(f"{venue}: quote {quote_id} does not say which side the requester took") + requester = current.accepted_side + ours = _opposite(requester) + price = current.ask if requester == Side.BUY else current.bid + if price is None: + raise BadRequest(f"{venue}: quote {quote_id} has no price on the side the requester took") + quantity = current.ask_quantity if requester == Side.BUY else current.bid_quantity + if quantity is None and held and held.get("quantity"): + quantity = Decimal(held["quantity"]) + if quantity is None: + rfq = self.incoming.get(f"{venue}:{current.rfq_id}") + if rfq is None: + try: + rfq = await adapter.fetch_rfq(current.rfq_id) + except Exception: + rfq = None + if rfq is not None and rfq.quantity is not None: + quantity = rfq.quantity + elif rfq is not None and rfq.amount is not None: + theirs = price if requester == Side.BUY else ONE - price + quantity = rfq.amount / theirs + if quantity is None: + raise BadRequest(f"{venue}: quote {quote_id} gives no size to confirm") + market_id = current.market_id or (held or {}).get("market_id") + if not market_id: + raise BadRequest(f"{venue}: quote {quote_id} names no instrument") + account = self._account(venue) + outcome, action = self._maker_leg(ours, venue, market_id, account) + book = (held or {}).get("book") + request = self._record( + market_id=market_id, key=key, account=account, type=OrderType.RFQ_MAKER, side=ours, quantity=quantity, + price=price, book=book, trader=(held or {}).get("trader"), outcome=outcome, action=action, + params={"rfq_id": current.rfq_id, "quote_id": quote_id, "requester_side": requester.value}, + ) + decision = self.engine.evaluate_order_risk(request, venue=venue, account=account, caller_supplied_order_id=True) + if not decision.ok: + raise await self._refused(decision, operation="rfq_quoter", market_id=market_id, book=book, + extra={"quote_id": quote_id, "client_order_id": key}) + await self._begin(request, key=key, venue=venue, account=account, operation="confirm_quote", target=quote_id) + self.engine.order_risk_checks.record_sent(request) + self.following[self._quote_key(venue, quote_id)] = key + try: + result = await adapter.confirm_quote(current) + except (NetworkError, asyncio.TimeoutError) as exc: + await self._publish("quote.in_doubt", {"client_order_id": key, "venue": venue, "quote_id": quote_id, + "error": f"{type(exc).__name__}: {exc}"}, key=key) + raise SubmissionInDoubt(f"{venue}: no answer to the confirm of quote {quote_id} ({exc}); the engine " + "resolves it against the venue", client_order_id=key) from exc + except ExchangeError as exc: + state = IntentState.REJECTED if isinstance(exc, (OrderRejected, BadRequest)) else IntentState.FAILED + await self.engine.journal.mark_intent(key, state, detail=str(exc)) + self.following.pop(self._quote_key(venue, quote_id), None) + raise + await self.engine.journal.mark_intent(key, IntentState.SENDING, detail=CONFIRMED_MARK) + await self._observe(venue, result, extra={"client_order_id": key, "book": book}) + return result + + def _maker_leg(self, side: Side, venue: str, market_id: str, account: Account) -> tuple[str, str]: + """What our side of a confirmed quote does: it closes what we hold + where it can (selling YES out of a long, NO out of a short), else it + opens, as `default_leg` says.""" + held = self.engine.order_risk_checks._net_contracts(account.key, venue, market_id) + if side == Side.SELL and held > 0: + return "yes", "sell" + if side == Side.BUY and held < 0: + return "no", "sell" + return default_leg(side) + + # -- halt ----------------------------------------------------------------- + + async def on_halt(self, scope: str = "*") -> dict[str, Any]: + """Cancel every open RFQ we asked and every live quote we made, on + every venue in scope: the ones the engine holds and any the venue + lists. One venue failing does not stop the others.""" + out: dict[str, Any] = {"rfqs": {}, "quotes": {}} + groups = getattr(self.engine, "rfq_groups", None) + if groups is not None: + # Groups first, so each records why its members went. + halted = await groups.on_halt(scope) + if halted: + out["groups"] = halted + for venue, adapter in self.engine.adapters.items(): + if scope not in ("*", venue): + continue + if adapter.has.get("rfq_taker"): + out["rfqs"][venue] = await self._halt_rfqs(venue, adapter) + if adapter.has.get("rfq_quoter"): + out["quotes"][venue] = await self._halt_quotes(venue, adapter) + return out + + async def _halt_rfqs(self, venue: str, adapter: TradingExchange) -> int | str: + wanted = {k.split(":", 1)[1] for k in self.rfqs if k.startswith(f"{venue}:")} + try: + wanted |= {r.id for r in await adapter.fetch_rfqs(status=RfqStatus.OPEN)} + except Exception as exc: + log.warning("synpath.engine: listing open RFQs on %s for the halt failed: %s", venue, exc) + count, failed = 0, [] + for rfq_id in sorted(wanted): + try: + await self.cancel_rfq(rfq_id, venue=venue, reason="halt") + count += 1 + except Exception as exc: + failed.append(f"{rfq_id}: {type(exc).__name__}: {exc}") + return count if not failed else f"canceled {count}, failed: {'; '.join(failed)}" + + async def _halt_quotes(self, venue: str, adapter: TradingExchange) -> int | str: + wanted = {k.split(":", 1)[1]: v.get("rfq_id") for k, v in self.maker_quotes.items() if k.startswith(f"{venue}:")} + try: + for quote in await adapter.fetch_my_quotes(status=QuoteStatus.OPEN): + wanted.setdefault(quote.id, quote.rfq_id) + except Exception as exc: + log.warning("synpath.engine: listing our quotes on %s for the halt failed: %s", venue, exc) + count, failed = 0, [] + for quote_id, rfq_id in sorted(wanted.items()): + try: + await self.cancel_quote(quote_id, rfq_id=rfq_id, venue=venue, reason="halt") + count += 1 + except Exception as exc: + failed.append(f"{quote_id}: {type(exc).__name__}: {exc}") + return count if not failed else f"canceled {count}, failed: {'; '.join(failed)}" + + # -- recovery of offers --------------------------------------------------- + + async def _resolve_offer(self, adapter: TradingExchange, intent: Intent, base: dict[str, Any]) -> dict[str, Any]: + """A create, cancel or quote send in doubt, read back from the venue.""" + venue, key, op = intent.venue, intent.client_order_id, intent.operation + request = OrderRequest.stored(intent.request) + young = intent.age_ms < self.config.lost_after_s * 1000 + if op == "create_rfq": + since = intent.created_ts - RECENT_MS + known = {k.split(":", 1)[1] for k in self.rfqs if k.startswith(f"{venue}:")} + for rfq in await adapter.fetch_rfqs(status=RfqStatus.OPEN, market_id=request.market_id): + if rfq.id not in known and (rfq.created_at is None or rfq.created_at >= since): + await self._track_rfq(venue, rfq, book=request.book, trader=request.trader, key=key) + return base | {"result": "adopted", "rfq_id": rfq.id} + elif op == "cancel_rfq": + try: + rfq = await adapter.fetch_rfq(intent.target_order_id) + except RfqNotFound: + rfq = None + if rfq is None or rfq.status == RfqStatus.CLOSED: + await self._rfq_closed(venue, intent.target_order_id, key=key, reason="canceled", + detail=str(request.params.get("reason") or "")) + return base | {"result": "adopted"} + await self.engine.journal.mark_intent(key, IntentState.FAILED, detail="the venue still shows the RFQ open") + return base | {"result": "swept"} + elif op == "cancel_quote": + try: + quote = await adapter.fetch_quote(intent.target_order_id, rfq_id=request.params.get("rfq_id")) + except RfqNotFound: + quote = None + if quote is None or quote.is_terminal: + await self.engine.journal.mark_intent(key, IntentState.SETTLED, order_id=intent.target_order_id) + await self._quote_gone(venue, intent.target_order_id, rfq_id=request.params.get("rfq_id"), + reason=str(request.params.get("reason") or "canceled"), + status=QuoteStatus.CANCELED) + return base | {"result": "adopted"} + await self.engine.journal.mark_intent(key, IntentState.FAILED, detail="the venue still shows the quote live") + return base | {"result": "swept"} + elif op in ("send_quote", "replace_quote"): + rfq_id = request.params.get("rfq_id") + known = {k.split(":", 1)[1] for k in self.maker_quotes if k.startswith(f"{venue}:")} + since = intent.created_ts - RECENT_MS + for quote in await adapter.fetch_my_quotes(rfq_id=rfq_id, status=QuoteStatus.OPEN): + if quote.id not in known and (quote.created_at is None or quote.created_at >= since): + maker = MakerQuote.model_validate(request.params["maker_quote"]) + await self._track_quote(venue, quote, quote=maker, key=key, market_id=request.market_id, + replaces=request.params.get("replaces")) + return base | {"result": "adopted", "quote_id": quote.id} + if young: + await self.engine.bus.publish("intent.pending", base | {"age_ms": intent.age_ms}) + return base | {"result": "pending", "age_ms": intent.age_ms} + await self.engine.journal.mark_intent(key, IntentState.LOST, detail="not found at the venue") + await self.engine.bus.publish("intent.swept", base | {"reason": "nothing at the venue matches it"}) + return base | {"result": "swept"} + + async def restore(self) -> None: + """At start: the working sets from the journal, the combos, the + accepts and confirms to follow, and the venues' quote streams.""" + journal = self.engine.journal + for venue in self.engine.adapters: + for name, target in ((f"rfq:open:{venue}", "rfqs"), (f"rfq:maker_quotes:{venue}", "maker_quotes")): + raw = await journal.cursor(name) + if not raw: + continue + for row in json.loads(raw): + if target == "rfqs": + self.rfqs[f"{venue}:{row['rfq']['id']}"] = row + else: + self.maker_quotes[f"{venue}:{row['id']}"] = row + for event in await journal.events_page(kinds=["combo.saved"], limit=10_000): + market_id = event.payload.get("market_id") + if market_id and market_id not in self.combos: + self.combos[market_id] = Combo.model_validate(event.payload) + in_doubt = [i for i in await journal.intents(state=IntentState.SENDING) + if i.operation in FOLLOWED and i.target_order_id] + for intent in in_doubt: + self.following[self._quote_key(intent.venue, intent.target_order_id)] = intent.client_order_id + await self._reseed(in_doubt) + for venue, adapter in self.engine.adapters.items(): + subscribe = getattr(adapter, "subscribe_quotes", None) + if callable(subscribe) and venue not in self._subscribed: + self._subscribed.add(venue) + subscribe(lambda quote, v=venue: self.on_quote(quote, venue=v)) + self._subscribe_incoming(venue, adapter) + + async def _reseed(self, in_doubt: list[Intent]) -> None: + """Hand each adapter what its venue cannot list back after a restart + (`TradingExchange.restore_rfqs`): our open RFQs from the working set, + and the quotes whose accept or confirm is in doubt, at the status the + intent implies. Polymarket global needs it (its gateway lists no + RFQs); venues that list their own ignore it.""" + journal = self.engine.journal + for venue, adapter in self.engine.adapters.items(): + restore = getattr(adapter, "restore_rfqs", None) + if not callable(restore): + continue + rfqs: dict[str, Rfq] = {} + for key, held in self.rfqs.items(): + if key.startswith(f"{venue}:"): + try: + rfqs[held["rfq"]["id"]] = Rfq.model_validate(held["rfq"]) + except Exception as exc: + log.warning("synpath.engine: RFQ %s in the working set does not validate: %s", key, exc) + quotes: list[RfqQuote] = [] + for intent in in_doubt: + if intent.venue != venue: + continue + request = OrderRequest.stored(intent.request) + rfq_id = str(request.params.get("rfq_id") or "") + answered = intent.detail in (ACCEPTED_MARK, CONFIRMED_MARK) + if intent.operation == "accept_quote": + if rfq_id and rfq_id not in rfqs: + # Gone from the working set: the create's own event has it. + found = await journal.events_page(kinds=["rfq.created"], keys=[f"{venue}:rfq:{rfq_id}"], limit=1) + if found: + body = {k: v for k, v in found[0].payload.items() if k in Rfq.model_fields} + try: + rfqs[rfq_id] = Rfq.model_validate(body) + except Exception as exc: + log.warning("synpath.engine: RFQ %s cannot be rebuilt from its event: %s", rfq_id, exc) + price = request.price if request.price is not None else ONE + buy = request.side == Side.BUY + quotes.append(RfqQuote(id=intent.target_order_id, rfq_id=rfq_id or "", venue=venue, + market_id=request.market_id, status=QuoteStatus.ACCEPTED, + ask=price if buy else None, bid=None if buy else price, + accepted_side=request.side, mine=False, updated_at=intent.created_ts, + info={"client_order_id": intent.client_order_id, "answered": answered})) + else: + # The latest event of our quote that carries its full + # body (the accepted one has the size the requester took). + found = [e for e in await journal.events_page( + kinds=["quote.sent", "quote.accepted", "quote.confirmed"], + keys=[self._quote_key(venue, intent.target_order_id)], limit=10) + if e.payload.get("rfq_id") and e.payload.get("venue")] + if not found: + continue + body = {k: v for k, v in found[0].payload.items() if k in RfqQuote.model_fields} + status = QuoteStatus.CONFIRMED if intent.detail == CONFIRMED_MARK else QuoteStatus.ACCEPTED + try: + quotes.append(RfqQuote.model_validate({**body, "status": status, "mine": True})) + except Exception as exc: + log.warning("synpath.engine: quote %s cannot be rebuilt from its event: %s", + intent.target_order_id, exc) + if not rfqs and not quotes: + continue + try: + restore(list(rfqs.values()), quotes) + except Exception as exc: + log.warning("synpath.engine: re-seeding RFQs at %s failed: %s", venue, exc) + + # -- incoming RFQs: pushed, or polled as a fallback ----------------------- + + def on_incoming_rfq(self, callback: Callable[[Rfq], Any]) -> Callable[[], None]: + """Register a quoting callback: `callback(rfq)` (sync or async) runs + as soon as an incoming RFQ is recorded and `rfq.incoming` is out, + before anything else, so it can `engine.send_quote` inside the + venue's window (Polymarket global 400 ms, SX Bet 60 s). On a venue + that pushes, that is the moment the stream delivers it; elsewhere + the 2 s poll. A callback that raises is logged. Returns the + unsubscribe.""" + self._incoming_callbacks.append(callback) + return lambda: self._incoming_callbacks.remove(callback) if callback in self._incoming_callbacks else None + + async def on_incoming(self, venue: str, rfq: Rfq) -> bool: + """An incoming RFQ, from a venue stream or the poll. In order: it is + recorded in `incoming`, `rfq.incoming` is published (not journaled), + and every `on_incoming_rfq` callback runs, awaited. Its combo's legs + are journaled after that. `False` for one already known (or ours).""" + key = f"{venue}:{rfq.id}" + if key in self.incoming or rfq.mine: + return False + self.incoming[key] = rfq + await self._publish("rfq.incoming", rfq.model_dump(mode="json"), key=f"{venue}:rfq:{rfq.id}", persist=False) + for callback in list(self._incoming_callbacks): + try: + result = callback(rfq) + if inspect.isawaitable(result): + await result + except Exception: + log.exception("synpath.engine: an incoming-RFQ callback failed on %s", key) + if rfq.combo is not None: + await self.save_combo(rfq.combo) + return True + + def _subscribe_incoming(self, venue: str, adapter: TradingExchange) -> None: + if venue in self._incoming_subscribed or not adapter.has.get("rfq_quoter"): + return + subscribe = getattr(adapter, "subscribe_incoming_rfqs", None) + if not callable(subscribe): + return + self._incoming_subscribed.add(venue) + if subscribe(lambda rfq, v=venue: self.on_incoming(v, rfq)) is not None: + self.pushed.add(venue) + + async def start_streams(self) -> None: + """Start each pushing venue's incoming stream, once, and take what it + already heard. A venue whose stream will not start is polled instead.""" + if self.config.rfq_incoming_interval_s is None or not self.config.rfq_stream_incoming: + return + for venue in sorted(self.pushed - self._streams_started): + adapter = self.engine.adapters.get(venue) + if adapter is None: + continue + self._streams_started.add(venue) + try: + await adapter.start_incoming_rfqs() + for rfq in await adapter.fetch_incoming_rfqs(): + await self.on_incoming(venue, rfq) + except Exception as exc: + log.warning("synpath.engine: the incoming-RFQ stream at %s did not start (%s); polling it instead", + venue, exc) + self.pushed.discard(venue) + + def _prune_pushed(self) -> None: + """Pushed RFQs leave `incoming` a minute after their window (or after + ten minutes when they name none); polled ones leave when the poll no + longer lists them.""" + now = _now_ms() + for key, rfq in list(self.incoming.items()): + if key.split(":", 1)[0] not in self.pushed: + continue + end = rfq.expires_at if rfq.expires_at is not None else (rfq.created_at or now) + 540_000 + if end + 60_000 < now: + self.incoming.pop(key, None) + + # -- the loop ------------------------------------------------------------- + + async def run(self) -> None: + """The follower: accepts and confirms, quotes on our RFQs, our maker + quotes and their expiry, and the incoming feed (venue streams are + started first, so pushed RFQs flow from the start).""" + try: + await self.start_streams() + except Exception: + log.exception("synpath.engine: starting the incoming-RFQ streams failed") + while self.engine.running: + await asyncio.sleep(self.config.rfq_poll_interval_s) + try: + await self.tick() + except Exception: + log.exception("synpath.engine: the RFQ follower failed") + + async def tick(self) -> None: + """One pass of the follower, for a host or a test to drive.""" + await self.start_streams() + await self.follow() + await self.watch_rfqs() + await self.watch_maker_quotes() + groups = getattr(self.engine, "rfq_groups", None) + if groups is not None: + await groups.tick() + self._prune_pushed() + interval = self.config.rfq_incoming_interval_s + if interval is not None and time.monotonic() - self._incoming_ts >= interval: + self._incoming_ts = time.monotonic() + await self.watch_incoming() + + async def follow(self) -> None: + for intent in await self.engine.journal.intents(state=IntentState.SENDING): + if intent.operation not in FOLLOWED or self.engine._lock(intent.client_order_id).locked(): + continue + try: + await self._resolve_followed(intent) + except Exception: + log.exception("synpath.engine: following %s failed", intent.client_order_id) + + async def watch_rfqs(self) -> None: + for key, held in list(self.rfqs.items()): + venue, rfq_id = key.split(":", 1) + adapter = self.engine.adapters.get(venue) + if adapter is None: + continue + try: + quotes = await adapter.fetch_quotes(rfq_id) + except RfqNotFound: + await self._rfq_closed(venue, rfq_id, key=None, reason="expired") + continue + except Exception as exc: + log.warning("synpath.engine: reading quotes on RFQ %s at %s failed: %s", rfq_id, venue, exc) + continue + for quote in quotes: + if self._quote_key(venue, quote.id) in self.following: + continue + await self._observe(venue, quote, extra={"book": held.get("book")}) + if quote.status == QuoteStatus.EXECUTED and key in self.rfqs: + await self._rfq_closed(venue, rfq_id, key=None, reason="executed", detail=quote.id) + if key in self.rfqs: + await self._watch_rfq_status(venue, rfq_id, key, held, adapter) + + async def _watch_rfq_status(self, venue: str, rfq_id: str, key: str, held: dict[str, Any], adapter: Any) -> None: + """Close our RFQ here when its venue has closed it (its expiry, or + any close the quotes do not show): read every + `RFQ_STATUS_INTERVAL_S`, and at once past its `expires_at`.""" + expires = (held.get("rfq") or {}).get("expires_at") + due = expires is not None and int(expires) <= _now_ms() + now = time.monotonic() + if not due and now - self._rfq_read.get(key, 0.0) < RFQ_STATUS_INTERVAL_S: + return + if not callable(getattr(adapter, "fetch_rfq", None)): + if due: + await self._rfq_closed(venue, rfq_id, key=None, reason="expired", detail="past its expires_at") + return + self._rfq_read[key] = now + try: + rfq = await adapter.fetch_rfq(rfq_id) + except RfqNotFound: + await self._rfq_closed(venue, rfq_id, key=None, reason="expired") + return + except Exception as exc: + log.info("synpath.engine: reading RFQ %s at %s failed: %s", rfq_id, venue, exc) + return + if rfq.status == RfqStatus.CLOSED: + reason = rfq.closed_reason.value if rfq.closed_reason is not None else "expired" + await self._rfq_closed(venue, rfq_id, key=None, reason=reason if reason != "replaced" else "canceled", + detail="closed at the venue") + elif rfq.expires_at is not None and held.get("rfq", {}).get("expires_at") != rfq.expires_at: + held["rfq"]["expires_at"] = rfq.expires_at + + async def watch_maker_quotes(self) -> None: + now = _now_ms() + by_venue: dict[str, list[tuple[str, dict[str, Any]]]] = {} + for key, held in list(self.maker_quotes.items()): + by_venue.setdefault(held["venue"], []).append((key, held)) + for venue, rows in by_venue.items(): + adapter = self.engine.adapters.get(venue) + if adapter is None: + continue + for _, held in rows: + expires = held.get("expires_at") + qkey = self._quote_key(venue, held["id"]) + if expires is not None and int(expires) <= now and qkey not in self.following: + status = self._status.get(qkey) + if status in (None, QuoteStatus.OPEN, QuoteStatus.ACCEPTED): + try: + await self.cancel_quote(held["id"], rfq_id=held.get("rfq_id"), venue=venue, reason="expired") + except Exception as exc: + log.warning("synpath.engine: expiring quote %s at %s failed: %s", held["id"], venue, exc) + try: + mine = {q.id: q for q in await adapter.fetch_my_quotes()} + except Exception as exc: + log.warning("synpath.engine: reading our quotes at %s failed: %s", venue, exc) + continue + for key, held in rows: + quote = mine.get(held["id"]) + if quote is None or key not in self.maker_quotes: + continue + if self._quote_key(venue, quote.id) in self.following: + continue + await self._observe(venue, quote.model_copy(update={"expires_at": held.get("expires_at")}), + extra={"book": held.get("book")}) + if quote.is_terminal: + self.maker_quotes.pop(key, None) + await self._save_working_set(venue) + + async def watch_incoming(self) -> None: + """The fallback poll, for quoting venues without a stream.""" + for venue, adapter in self.engine.adapters.items(): + if not adapter.has.get("rfq_quoter") or venue in self.pushed: + continue + try: + page = await adapter.fetch_incoming_rfqs() + except Exception as exc: + log.warning("synpath.engine: reading incoming RFQs at %s failed: %s", venue, exc) + continue + seen = set() + for rfq in page: + seen.add(f"{venue}:{rfq.id}") + await self.on_incoming(venue, rfq) + for key in [k for k in self.incoming if k.startswith(f"{venue}:") and k not in seen]: + self.incoming.pop(key, None) diff --git a/synpath/engine/rfq_group.py b/synpath/engine/rfq_group.py new file mode 100644 index 0000000..f7d7883 --- /dev/null +++ b/synpath/engine/rfq_group.py @@ -0,0 +1,1512 @@ +"""Cross-venue RFQ: one request, every venue that lists it, one ranked list. + +`docs/rfq-combo.md`, "Cross-venue RFQ", is the design; this is the code +behind `engine.rfq_groups` and the `Engine` methods that delegate to it. It +sits on the RFQ desk (`synpath.engine.rfq`): every member is an ordinary +RFQ of the desk's, created, watched, cancelled, accepted and recovered the +way any RFQ is. What a group adds is which venues to ask, one ranking of +everything they answer, and the rule that only one of them trades. + +**Instrument resolution.** A group is on one of three things: + +* a bucket (`bucket:`): one member per enabled bucket member, on that + member's market, with its `flip`; +* a combo: legs (market id and outcome, each on any venue or a bucket), + or a combo's own market id the engine holds the legs of. Each venue that + has `combos` and `rfq_taker` gets each leg mapped onto its own market (the + leg itself on its own venue, a bucket's member there, else the hosted + matching service, `synpath.matching.match_market`, with its `side_map`), + then its eligibility checked (Kalshi: an open collection whose + `associated_event_tickers` take every leg's event; Polymarket and + Polymarket US: the venue's combo-eligible list), then its combo created + (Kalshi with that `collection_id`). A venue where a leg has no market or is + not eligible is skipped, with the reason; +* a plain market: the market's own venue, and with `cross_venue=True` its + matched market on every other RFQ venue, with the match's `side_map`. + +**Prices in the group's terms.** A member whose YES is the group's NO (a +flipped bucket member, a match whose `side_map` swaps the sides) is asked +and accepted on the other side: buying the group's YES is buying that +member's NO, so its quote's bid `b` is the group's ask `1 - b`. Every quote +is ranked in the group's YES terms after the venue's taker fee for the size +the accept would trade (`fetch_fee_estimate`, the venue's own schedule, as +the router prices a bucket), and an amount-sized group is ranked by the +contracts the amount buys after that fee. + +**One accept per group, in the journal.** An accept claims the group with +an intent keyed `rfq-group-accept:{group}:{attempt}`. A claim is refused +while any earlier attempt is not `failed`, `rejected` or `lost`, and the +key is re-read after it is written, so two accepts cannot both hold it. +Only then are the other members cancelled (a venue with no requester +cancel, Polymarket global, is left to expire and recorded so), and the +chosen quote is accepted through `RfqDesk.accept_quote`: risk, the journal, +following and recovery are the desk's. An accept that fails releases the +claim; the group can be accepted again on a quote still open, and what was +cancelled stays cancelled. A quote on a member RFQ cannot be accepted +around the group (`RfqDesk.accept_quote` refuses it). + +**What a restart finds.** The groups not yet final are in the cursors +table (`rfq:groups`), saved at every step. `restore()` reads them back and +re-seeds any member RFQ the desk's working set lacks (`restore_rfqs`). A +member whose create was in doubt is matched to its create intent once the +desk has resolved it. A claim whose accept was never sent (the process died +between the cancels and the accept) finishes the cancels it planned and +is released, nothing traded; one whose accept was sent is followed by the +desk and the group follows its intent. +""" +from __future__ import annotations + +import asyncio +import json +import logging +import time +import uuid +from decimal import ROUND_DOWN, Decimal +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal, Mapping + +from pydantic import Field + +from .. import ids +from ..bucket import Bucket, bucket_id_of, is_bucket_id +from ..errors import BadRequest, MarketNotFound +from ..trading.errors import InvalidOrder, RfqNotFound, RiskRejected +from ..trading.money import FALLBACK_QUANTITY_STEP, contracts_for_amount, outcome_price +from ..trading.types import ( + AcceptQuote, Action, Combo, ComboLeg, ComboMarket, OrderType, Outcome, QuoteStatus, Rfq, RfqQuote, Side, yes_side, +) +from ..types import MarketLink, _Base +from .storage_types import Intent, IntentState +from .submissions import SubmissionInDoubt + +if TYPE_CHECKING: + from .engine import Engine + +log = logging.getLogger("synpath.engine.rfq_group") + +ZERO = Decimal("0") +ONE = Decimal("1") + +GROUPS_CURSOR = "rfq:groups" +# Pages of a venue's combo-eligible list (or of its collections) read +# looking for a group's legs before a leg not yet seen counts as ineligible. +ELIGIBLE_PAGES = 20 +# How long a venue's combo-eligible list is trusted between groups. +ELIGIBLE_TTL_S = 60.0 +# How long a matching answer (a listing, or none) is reused: a preview and +# the group it leads to ask the same legs within seconds. +MATCH_TTL_S = 60.0 + +# `labeler(legs)` -> a short readable name for each leg's market, by market +# id (`Al Fayha to win`, `U.S. invades Iran`). serve's combo board warmer is +# one (`synpath.server.combo_warmer`); without one a leg is named by its +# `info["label"]`, else its market id. +Labeler = Callable[[list[ComboLeg]], Awaitable[dict[str, str]]] + +VENUE_NAMES = {"kalshi": "Kalshi", "polymarket": "Polymarket", "polymarket_us": "Polymarket US", + "limitless": "Limitless", "opinion": "Opinion", "predict_fun": "predict.fun", "sx_bet": "SX Bet"} +# What a collection venue's combos take, said when a leg's event is in none +# of its open collections. +COLLECTION_SCOPES = {"kalshi": "Kalshi combos are sports and cross-category only"} +# Why a venue lacks a leg, in the order a skip reason names them. +LEG_CODES = ("missing_leg", "matching_error", "ineligible_leg", "outside_collections") + +# `matcher(anchor, venue)` -> the other venue's listing of the same +# proposition, or `None` when it has none. Raises `MarketNotFound` when the +# anchor is not a listing the service knows. +Matcher = Callable[[str, str], Awaitable[MarketLink | None]] + +GroupStatus = Literal["creating", "open", "accepting", "accepted", "executed", "canceled", "expired", "failed"] +MemberStatus = Literal["creating", "open", "failed", "canceled", "lapsing", "closed", "accepting", "accepted", + "executed"] +FINAL = frozenset({"executed", "canceled", "expired", "failed"}) +# A claim in one of these states no longer holds its group. +RELEASED = (IntentState.FAILED, IntentState.REJECTED, IntentState.LOST) + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _opposite(side: Side) -> Side: + return Side.SELL if side == Side.BUY else Side.BUY + + +def _flip_outcome(outcome: str) -> Outcome: + return "no" if outcome == "yes" else "yes" # type: ignore[return-value] + + +def lists_combo_markets(adapter: Any) -> bool: + """The adapter answers `fetch_combo_eligible_markets` itself (the base + class's default is `NotSupported`).""" + from ..trading.base import TradingExchange + + method = getattr(type(adapter), "fetch_combo_eligible_markets", None) + return method is not None and method is not TradingExchange.fetch_combo_eligible_markets + + +def hosted_matcher(*, base_url: str | None = None, client: Any = None, api_key: str | None = None) -> Matcher: + """The hosted matching service (`synpath.matching.match_market`) as a + group's matcher: one blocking HTTP read per leg and venue, in a thread.""" + from ..matching import match_market + + async def match(anchor: str, venue: str) -> MarketLink | None: + found = await asyncio.to_thread(match_market, anchor, venue=venue, base_url=base_url, client=client, + api_key=api_key) + return found.matched + + return match + + +# --------------------------------------------------------------------------- +# The model +# --------------------------------------------------------------------------- + +class GroupInstrument(_Base): + """What a group asks about.""" + + kind: Literal["market", "bucket", "combo"] + market_id: str | None = Field(default=None, description="The market, `bucket:`, or the combo's own id " + "the legs came from") + legs: list[ComboLeg] | None = Field(default=None, description="A combo's legs, as given (any venue, or a bucket)") + + +class GroupMember(_Base): + """One venue's RFQ in a group.""" + + venue: str + instrument_id: str | None = Field(default=None, description="The market or combo asked about on this venue") + flip: bool = Field(default=False, description="This member's YES is the group's NO") + via: Literal["anchor", "bucket", "matching", "combo"] = "anchor" + legs: list[ComboLeg] | None = Field(default=None, description="A combo's legs on this venue, after mapping") + combo: Combo | None = None + collection_id: str | None = None + eligibility: str | None = Field(default=None, description="How the legs were found eligible") + create_key: str | None = Field(default=None, description="The member RFQ's create intent") + rfq_id: str | None = None + rfq: dict[str, Any] | None = None + status: MemberStatus = "creating" + detail: str | None = None + accept_key: str | None = Field(default=None, description="The accept intent, once this member's quote is taken") + + @property + def key(self) -> str: + return f"{self.venue}:{self.rfq_id}" + + +class SkippedLeg(_Base): + """One leg a venue lacks, and why.""" + + market_id: str = Field(description="The leg, as given") + label: str = Field(description="A short readable name for the leg's market (`Al Fayha to win`, `U.S. invades " + "Iran`), never just its outcome") + code: str = Field(description="`missing_leg`: no matching market there; `matching_error`: the matching service " + "could not be asked; `ineligible_leg`: matched, but the venue's combos do not take " + "that market; `outside_collections`: matched, but its event is in none of the " + "venue's open combo collections (Kalshi)") + mapped_id: str | None = Field(default=None, description="The venue's matched market, where there is one") + detail: str | None = Field(default=None, description="What matching or the venue said") + + +class SkippedVenue(_Base): + """A venue the group does not ask, and why.""" + + venue: str + code: str = Field(description="`excluded`, `forbidden`, `not_configured`, `no_rfq`, `no_combos`, `no_match`, " + "`matching_error`, `missing_leg`, `ineligible_leg`, `outside_collections`, " + "`duplicate_leg`, `one_sided_size`, `member_disabled`. A combo venue lacking legs " + "has the code of the first reason in `missing_legs`, in that order") + reason: str = Field(description="For a person: a combo venue's names every leg it lacks (`no match for “Al " + "Fayha to win”, “U.S. invades Iran”`)") + leg: str | None = Field(default=None, description="The first leg (as given) that could not be placed here") + missing_legs: list[SkippedLeg] = Field(default_factory=list, description="A combo: every leg this venue lacks, " + "each with its label and reason") + detail: str | None = Field(default=None, description="What matching or the venue said, for a log") + + +class GroupQuote(_Base): + """One quote, in the group's terms. Prices are YES prices of the group's + instrument (a flipped member's are turned over); `fee` is the venue's + taker fee for `quantity` contracts.""" + + quote_id: str + venue: str + rfq_id: str + market_id: str | None = None + flip: bool = False + status: QuoteStatus + maker: str | None = None + raw_bid: Decimal | None = None + raw_ask: Decimal | None = None + raw_price: Decimal | None = Field(default=None, description="The member's own YES price for the requested side: " + "what the venue trades at") + price: Decimal | None = Field(default=None, description="The same, as the group's YES price") + outcome_price: Decimal | None = Field(default=None, description="What one contract of the requested outcome " + "costs (buy) or fetches (sell), before fees") + available: Decimal | None = Field(default=None, description="Contracts the quote offers on that side") + quantity: Decimal | None = Field(default=None, description="Contracts an accept would trade") + fee: Decimal | None = Field(default=None, description="Taker fee for `quantity`; null when the venue would " + "not price it (counted as zero)") + fee_per_contract: Decimal | None = None + effective_price: Decimal | None = Field(default=None, description="`price` plus the fee per contract on a YES " + "buy, minus it on a YES sell") + effective_outcome_price: Decimal | None = Field(default=None, description="`outcome_price` with the fee per " + "contract: the ranking key") + partial: bool = Field(default=False, description="The quote offers fewer contracts than asked") + rank: int | None = Field(default=None, description="1 is best; null for a quote that cannot be accepted now") + note: str | None = None + expires_at: int | None = Field(default=None, description="When the quote stops being acceptable, ms, as the " + "venue says: the quote's own expiry (Polymarket " + "`expires_at`), else its RFQ's. Null where the venue " + "gives none (Kalshi, Polymarket US): live until the " + "venue closes it") + expires_from: Literal["quote", "rfq"] | None = Field(default=None, description="Which expiry `expires_at` is") + confirm_by: int | None = Field(default=None, description="After an accept: the maker's last look ends, ms") + execute_by: int | None = Field(default=None, description="After a confirm: the venue executes by, ms") + + +class RfqGroup(_Base): + """A cross-venue RFQ: one `Rfq` per venue, one ranked list of quotes.""" + + id: str + instrument: GroupInstrument + quantity: Decimal | None = None + amount: Decimal | None = None + outcome: Outcome = "yes" + action: Action = "buy" + cross_venue: bool = False + book: str | None = None + trader: str | None = None + tags: dict[str, str] = Field(default_factory=dict) + members: list[GroupMember] = Field(default_factory=list) + skipped: list[SkippedVenue] = Field(default_factory=list) + quotes: list[GroupQuote] = Field(default_factory=list, description="Best first; unrankable quotes last") + status: GroupStatus = "creating" + accept_attempts: int = 0 + claim_key: str | None = Field(default=None, description="The live or last accept claim") + accepted_quote_id: str | None = None + accepted_venue: str | None = None + order_id: str | None = None + failures: list[dict[str, Any]] = Field(default_factory=list) + expires_at: int | None = Field(default=None, description="The soonest venue expiry among the quotes that can " + "be accepted now, ms; null when none gives one") + created_at: int = Field(default_factory=_now_ms) + updated_at: int = Field(default_factory=_now_ms) + + @property + def side(self) -> Side: + """The YES-side the requester takes, in the group's terms.""" + return yes_side(self.outcome, self.action) + + @property + def final(self) -> bool: + return self.status in FINAL + + @property + def market_id(self) -> str: + return self.instrument.market_id or f"rfq-group:{self.id}" + + def venues(self) -> set[str]: + return {m.venue for m in self.members} + + def member_of(self, venue: str, rfq_id: str | None) -> GroupMember | None: + for member in self.members: + if member.venue == venue and member.rfq_id == rfq_id: + return member + return None + + def best(self) -> GroupQuote | None: + ranked = [q for q in self.quotes if q.rank is not None] + return ranked[0] if ranked else None + + +class PreviewVenue(_Base): + """One venue a group would ask, as `preview` resolves it: nothing created.""" + + venue: str + via: Literal["anchor", "bucket", "matching", "combo"] + instrument_id: str | None = Field(default=None, description="The market asked (a plain market or bucket " + "member); a combo has none until it is built") + flip: bool = False + legs: list[ComboLeg] | None = Field(default=None, description="A combo's legs on this venue, after mapping") + collection_id: str | None = None + eligibility: str | None = None + leg_prices: list[Decimal | None] | None = Field( + default=None, description="Each mapped leg's price in its outcome's terms (YES ask, or 1 - YES bid), " + "from the server's catalogue; filled in by the HTTP route") + combined_price: Decimal | None = Field(default=None, description="The leg prices multiplied: an estimate " + "of the combo's price, not a quote") + + +class GroupPreview(_Base): + """Which venues a group on this instrument would ask, and why the others + would be skipped: `create_group`'s planning without creating anything.""" + + instrument: GroupInstrument + offered: list[PreviewVenue] = Field(default_factory=list) + skipped: list[SkippedVenue] = Field(default_factory=list) + + +class GroupAccept(_Base): + """What an accept on a group did, as far as it is known yet.""" + + group: RfqGroup + quote: RfqQuote + venue: str + client_order_id: str = Field(description="The member accept's intent key: follow its events by it") + claim_key: str = Field(description="The group's claim intent") + + +# --------------------------------------------------------------------------- +# The desk +# --------------------------------------------------------------------------- + +class RfqGroupDesk: + """Cross-venue RFQ groups, attached as `engine.rfq_groups`.""" + + def __init__(self, engine: "Engine", *, matcher: Matcher | None = None): + self.engine = engine + self.matcher: Matcher = matcher or hosted_matcher() + # Names each leg's market in a skip reason (`Labeler`); set by serve. + self.labeler: Labeler | None = None + self.groups: dict[str, RfqGroup] = {} + # `venue:rfq_id` -> the group it is a member of. + self._by_rfq: dict[str, str] = {} + # Groups an accept or a cancel is working on in this process. + self._busy: set[str] = set() + self._fees: dict[tuple[str, str, str, Decimal, Decimal], Decimal | None] = {} + self._matches: dict[tuple[str, str], tuple[float, MarketLink | None]] = {} + self._eligible_lists: dict[str, tuple[float, dict[str, ComboMarket], bool]] = {} + + @property + def desk(self) -> Any: + return self.engine.rfq + + # -- lookups -------------------------------------------------------------- + + def group_of(self, venue: str, rfq_id: str | None) -> str | None: + """The group an RFQ of ours is a member of, if any.""" + return self._by_rfq.get(f"{venue}:{rfq_id}") if rfq_id else None + + async def get(self, group_id: str) -> RfqGroup: + """A group the engine holds, else the last state the journal recorded.""" + if group_id in self.groups: + return self.groups[group_id] + for event in await self.engine.journal.events_page(prefixes=["rfq_group."], keys=[f"rfq_group:{group_id}"], + limit=50): + body = event.payload.get("group") + if isinstance(body, dict): + return RfqGroup.model_validate(body) + raise RfqNotFound(f"no RFQ group {group_id!r}") + + def list(self, *, status: str | None = None) -> list[RfqGroup]: + rows = [g for g in self.groups.values() if status is None or g.status == status] + return sorted(rows, key=lambda g: -g.created_at) + + # -- persistence and events ----------------------------------------------- + + async def _save(self, group: RfqGroup) -> None: + group.updated_at = _now_ms() + self.groups[group.id] = group + for member in group.members: + if member.rfq_id: + self._by_rfq[member.key] = group.id + live = [g.model_dump(mode="json") for g in self.groups.values() if not g.final] + await self.engine.journal.set_cursor(GROUPS_CURSOR, json.dumps(live, default=str)) + + async def _publish(self, kind: str, group: RfqGroup, **extra: Any) -> None: + await self.engine.bus.publish(kind, {"group_id": group.id, "status": group.status, **extra, + "group": group.model_dump(mode="json")}, key=f"rfq_group:{group.id}") + + # -- create --------------------------------------------------------------- + + async def create_group( + self, instrument_id: str | None = None, *, legs: list[ComboLeg] | None = None, + quantity: Decimal | None = None, amount: Decimal | None = None, outcome: Outcome = "yes", + action: Action = "buy", cross_venue: bool = False, venues: list[str] | None = None, + exclude_venues: list[str] | None = None, refused_venues: Mapping[str, str] | None = None, + book: str | None = None, trader: str | None = None, tags: dict[str, str] | None = None, + ) -> RfqGroup: + """Ask every venue that lists the instrument (module doc). Returns the + group with its members (one RFQ per venue), the venues skipped and + why, and the quotes read so far. A venue whose create fails is a + `failed` member; the group stays open with the rest. `BadRequest` + (with `skipped` on the exception) when no venue can be asked.""" + await self.desk._lease() + if (quantity is None) == (amount is None): + raise BadRequest("size an RFQ group by quantity (contracts) or amount (money), exactly one") + if (instrument_id is None) == (not legs): + raise BadRequest("an RFQ group is on an instrument_id (a market, a bucket, a combo) or on legs, exactly one") + instrument = await self._instrument(instrument_id, legs) + group = RfqGroup(id=uuid.uuid4().hex, instrument=instrument, quantity=quantity, amount=amount, + outcome=outcome, action=action, cross_venue=cross_venue, book=book, trader=trader, + tags=dict(tags or {})) + group.members, group.skipped = await self._plan(group, include=venues, exclude=exclude_venues or [], + refused=dict(refused_venues or {})) + if not group.members: + exc = BadRequest("no venue can quote this: " + "; ".join(f"{s.venue}: {s.reason}" for s in group.skipped) + if group.skipped else "no venue with RFQ is configured") + exc.skipped = [s.model_dump(mode="json") for s in group.skipped] # type: ignore[attr-defined] + raise exc + key = f"rfq-group:{group.id}" + first = group.members[0].venue + request = self.desk._record(market_id=group.market_id, key=key, account=self.desk._account(first), + type=OrderType.RFQ_TAKER, quantity=quantity, book=book, trader=trader, tags=tags, + params={"group_id": group.id, "operation": "rfq_group_create", + "venues": [m.venue for m in group.members]}) + await self._save(group) + await self.desk._begin(request, key=key, venue=first, account=self.desk._account(first), + operation="rfq_group_create", target=group.id) + self._busy.add(group.id) + refusals: list[Exception] = [] + try: + for index, member in enumerate(group.members): + refusal = await self._create_member(group, member, index) + if refusal is not None: + refusals.append(refusal) + await self._settle_create(group, key) + finally: + self._busy.discard(group.id) + if group.status == "failed" and refusals and len(refusals) == len(group.members): + # Every venue refused it before sending (the halt, a paused book, + # a size limit): the caller gets the refusal, the journal the group. + raise refusals[0] + await self.refresh(group.id) + return group + + async def _create_member(self, group: RfqGroup, member: GroupMember, index: int) -> Exception | None: + """Create one member's combo (where it is one) and RFQ. Returns the + risk refusal when the engine's own rules refused it.""" + venue = member.venue + adapter = self.engine.adapters[venue] + try: + if member.via == "combo": + combo = await self.desk.create_combo(member.legs or [], venue=venue, collection_id=member.collection_id) + member.combo, member.instrument_id = combo, combo.market_id + member.create_key = f"rfq-create:{venue}:{group.id}-{index}" + await self._save(group) + outcome, action = self._ask_side(adapter, group, member) + rfq = await self.desk.create_rfq( + member.instrument_id, quantity=group.quantity, amount=group.amount, outcome=outcome, action=action, + combo=member.combo, venue=venue, book=group.book, trader=group.trader, + tags={**group.tags, "rfq_group": group.id}, key=member.create_key) + except SubmissionInDoubt as exc: + member.detail = f"in doubt: {exc}" + await self._save(group) + return None + except Exception as exc: # one venue failing must not stop the others + member.status, member.detail = "failed", f"{type(exc).__name__}: {exc}" + group.failures.append({"venue": venue, "stage": "create", "error": member.detail, "ts": _now_ms()}) + await self._save(group) + return exc if isinstance(exc, RiskRejected) else None + self._member_open(group, member, rfq) + await self._save(group) + return None + + def _member_open(self, group: RfqGroup, member: GroupMember, rfq: Rfq) -> None: + member.rfq_id, member.rfq, member.status = rfq.id, rfq.model_dump(mode="json"), "open" + member.detail = None + self._by_rfq[member.key] = group.id + + async def _settle_create(self, group: RfqGroup, key: str) -> None: + """The group's create intent once every member is created, failed or + in doubt: `sent` while any member stands, `rejected` when none does.""" + standing = [m for m in group.members if m.status != "failed"] + if group.status == "creating": + group.status = "open" if standing else "failed" + await self.engine.journal.mark_intent( + key, IntentState.SENT if standing else IntentState.REJECTED, order_id=group.id, + detail=f"{len([m for m in group.members if m.status == 'open'])} open, " + f"{len([m for m in group.members if m.status == 'failed'])} failed, " + f"{len([m for m in group.members if m.status == 'creating'])} in doubt") + await self._save(group) + await self._publish("rfq_group.created", group) + + def _ask_side(self, adapter: Any, group: RfqGroup, member: GroupMember) -> tuple[str | None, str | None]: + """What the member RFQ asks: nothing on a two-sided venue; on one that + asks a side (Polymarket global), the YES side the group takes there.""" + if not getattr(adapter, "rfq_one_sided", False): + return None, None + side = group.side if not member.flip else _opposite(group.side) + return "yes", "buy" if side == Side.BUY else "sell" + + async def preview( + self, instrument_id: str | None = None, *, legs: list[ComboLeg] | None = None, + quantity: Decimal | None = None, amount: Decimal | None = None, outcome: Outcome = "yes", + action: Action = "buy", cross_venue: bool = False, venues: list[str] | None = None, + exclude_venues: list[str] | None = None, refused_venues: Mapping[str, str] | None = None, + ) -> GroupPreview: + """What `create_group` would ask, without creating anything: each + venue's mapped market or legs (matching, eligibility, Kalshi's + collection) or why it would be skipped. Reads only (the matching + service, the venues' eligibility lists); nothing is journaled or sent. + Unsized, it plans a buy by amount, as a combo slip asks.""" + if (instrument_id is None) == (not legs): + raise BadRequest("a preview is on an instrument_id (a market, a bucket, a combo) or on legs, exactly one") + if quantity is None and amount is None: + amount = ONE + instrument = await self._instrument(instrument_id, legs) + group = RfqGroup(id="preview", instrument=instrument, quantity=quantity, amount=amount, + outcome=outcome, action=action, cross_venue=cross_venue) + members, skipped = await self._plan(group, include=venues, exclude=exclude_venues or [], + refused=dict(refused_venues or {})) + offered = [PreviewVenue(venue=m.venue, via=m.via, instrument_id=m.instrument_id, flip=m.flip, legs=m.legs, + collection_id=m.collection_id, eligibility=m.eligibility) for m in members] + return GroupPreview(instrument=instrument, offered=offered, skipped=skipped) + + async def _instrument(self, instrument_id: str | None, legs: list[ComboLeg] | None) -> GroupInstrument: + if legs: + if len(legs) < 2: + raise BadRequest("a combo needs at least two legs") + return GroupInstrument(kind="combo", legs=list(legs)) + assert instrument_id is not None + if is_bucket_id(instrument_id): + return GroupInstrument(kind="bucket", market_id=instrument_id) + combo = await self.desk.combo(instrument_id) + if combo is not None: + return GroupInstrument(kind="combo", market_id=instrument_id, legs=list(combo.legs)) + ids.venue_of(instrument_id) # a Synpath id, or BadRequest + return GroupInstrument(kind="market", market_id=instrument_id) + + # -- planning: which venues, on which markets ------------------------------ + + async def _plan(self, group: RfqGroup, *, include: list[str] | None, exclude: list[str], + refused: dict[str, str]) -> tuple[list[GroupMember], list[SkippedVenue]]: + adapters = self.engine.adapters + for name in [*(include or []), *exclude]: + if name not in adapters: + raise BadRequest(f"this engine does not trade {name}") + skipped: list[SkippedVenue] = [] + candidates: list[str] = [] + for venue, adapter in adapters.items(): + if not adapter.has.get("rfq_taker"): + continue + if venue in exclude or (include is not None and venue not in include): + skipped.append(SkippedVenue(venue=venue, code="excluded", reason="excluded by the request")) + elif venue in refused: + skipped.append(SkippedVenue(venue=venue, code="forbidden", reason=refused[venue])) + else: + candidates.append(venue) + kind = group.instrument.kind + if kind == "bucket": + members = await self._plan_bucket(group, candidates, skipped) + elif kind == "combo": + members = await self._plan_combo(group, candidates, skipped) + else: + members = await self._plan_market(group, candidates, skipped) + out: list[GroupMember] = [] + for member in members: + adapter = adapters[member.venue] + if getattr(adapter, "rfq_one_sided", False): + side = group.side if not member.flip else _opposite(group.side) + if (side == Side.BUY) != (group.amount is not None): + skipped.append(SkippedVenue( + venue=member.venue, code="one_sided_size", + reason=f"{member.venue} asks a YES buy by amount and a YES sell by quantity; this group is a " + f"YES {side.value} there by {'amount' if group.amount is not None else 'quantity'}")) + continue + out.append(member) + return out, skipped + + def _usable(self, venue: str, candidates: list[str], skipped: list[SkippedVenue], *, what: str) -> bool: + if venue in candidates: + return True + if any(s.venue == venue for s in skipped): + return False + adapter = self.engine.adapters.get(venue) + if adapter is None: + skipped.append(SkippedVenue(venue=venue, code="not_configured", reason=f"{what}: no adapter for {venue}")) + else: + skipped.append(SkippedVenue(venue=venue, code="no_rfq", reason=f"{what}: {venue} has no RFQ")) + return False + + async def _plan_market(self, group: RfqGroup, candidates: list[str], + skipped: list[SkippedVenue]) -> list[GroupMember]: + anchor = group.instrument.market_id or "" + home = ids.venue_of(anchor) + members = [] + if self._usable(home, candidates, skipped, what=anchor): + members.append(GroupMember(venue=home, instrument_id=anchor, via="anchor")) + if group.cross_venue: + for venue in candidates: + if venue == home: + continue + link, reason, code = await self._match(anchor, venue) + if link is None: + skipped.append(SkippedVenue(venue=venue, code=code, reason=reason, leg=anchor)) + continue + members.append(GroupMember(venue=venue, instrument_id=link.id, via="matching", + flip=link.side_map.get("yes") == "no")) + return members + + async def _bucket(self, market_id: str) -> Bucket: + row = await self.engine.journal.bucket(bucket_id_of(market_id)) + if row is None: + raise BadRequest(f"{market_id}: no such bucket in this journal") + bucket = Bucket.model_validate(row) + if bucket.status != "active": + raise BadRequest(f"{market_id}: the bucket is {bucket.status}") + return bucket + + async def _plan_bucket(self, group: RfqGroup, candidates: list[str], + skipped: list[SkippedVenue]) -> list[GroupMember]: + bucket = await self._bucket(group.instrument.market_id or "") + members = [] + for member in bucket.members: + venue = ids.venue_of(member.market_id) + if not member.enabled: + skipped.append(SkippedVenue(venue=venue, code="member_disabled", leg=member.market_id, + reason=f"{member.market_id} is switched off in the bucket")) + continue + if self._usable(venue, candidates, skipped, what=member.market_id): + members.append(GroupMember(venue=venue, instrument_id=member.market_id, flip=member.flip, + via="bucket")) + return members + + async def _match(self, anchor: str, venue: str) -> tuple[MarketLink | None, str, str]: + held = self._matches.get((anchor, venue)) + now = time.monotonic() + try: + if held is not None and now - held[0] <= MATCH_TTL_S: + link = held[1] + else: + link = await self.matcher(anchor, venue) + self._matches[(anchor, venue)] = (now, link) + except MarketNotFound as exc: + return None, f"the matching service does not know {anchor} ({exc})", "no_match" + except Exception as exc: + return None, f"matching {anchor} on {venue} failed: {type(exc).__name__}: {exc}", "matching_error" + if link is None or link.venue != venue: + return None, f"{venue} lists nothing matching {anchor}", "no_match" + return link, "", "" + + async def _map_leg(self, leg: ComboLeg, venue: str) -> tuple[ComboLeg | None, str, str]: + """A leg as `venue` names it, or why it has none there.""" + if is_bucket_id(leg.market_id): + bucket = await self._bucket(leg.market_id) + for member in bucket.enabled_members(): + if ids.venue_of(member.market_id) == venue: + outcome = _flip_outcome(leg.outcome) if member.flip else leg.outcome + return ComboLeg(market_id=member.market_id, outcome=outcome), "", "" + return None, f"bucket {leg.market_id} has no enabled member on {venue}", "missing_leg" + if ids.venue_of(leg.market_id) == venue: + return leg, "", "" + link, reason, code = await self._match(leg.market_id, venue) + if link is None: + return None, reason, "missing_leg" if code == "no_match" else code + outcome = leg.outcome if link.side_map.get(leg.outcome, leg.outcome) == leg.outcome else _flip_outcome(leg.outcome) + return ComboLeg(market_id=link.id, outcome=outcome), "", "" + + async def _plan_combo(self, group: RfqGroup, candidates: list[str], + skipped: list[SkippedVenue]) -> list[GroupMember]: + """Each combo venue's legs (mapped, then checked eligible), or every + leg it lacks and why: no match, matching unavailable, matched but not + combo-eligible there, or outside its combo collections.""" + legs = group.instrument.legs or [] + labels: dict[str, str] | None = None + members = [] + for venue in candidates: + adapter = self.engine.adapters[venue] + if not adapter.has.get("combos"): + skipped.append(SkippedVenue(venue=venue, code="no_combos", reason=f"{venue} has no combos")) + continue + mapped: list[ComboLeg] = [] + lacking: list[tuple[ComboLeg, str, str, str | None]] = [] + for leg in legs: + found, reason, code = await self._map_leg(leg, venue) + if found is None: + lacking.append((leg, code, reason, None)) + else: + mapped.append(found) + if not lacking and len({leg.market_id for leg in mapped}) != len(mapped): + skipped.append(SkippedVenue(venue=venue, code="duplicate_leg", + reason=f"two legs are the same market on {venue}")) + continue + collection = how = None + together = False + if not lacking: + try: + mapped, collection, how = await self._eligible(venue, adapter, mapped, legs) + except _Ineligible as exc: + together = exc.together + lacking = exc.legs or [(next(g for g in legs if g.market_id == exc.leg) if exc.leg else legs[-1], + exc.code, str(exc), None)] + if lacking: + labels = labels if labels is not None else await self._labels(legs) + skipped.append(skipped_combo_venue(venue, lacking, labels, together=together)) + continue + members.append(GroupMember(venue=venue, via="combo", legs=mapped, collection_id=collection, + eligibility=how)) + return members + + async def _labels(self, legs: list[ComboLeg]) -> dict[str, str]: + """A short name for each leg's market (`Labeler`), best effort.""" + found: dict[str, str] = {} + if self.labeler is not None: + try: + found = dict(await self.labeler(legs)) + except Exception as exc: # a label is for reading; a skip must still be reported + log.info("synpath.engine: labelling combo legs failed: %s", exc) + return {leg.market_id: found.get(leg.market_id) or str(leg.info.get("label") or "") or leg.market_id + for leg in legs} + + # -- eligibility ----------------------------------------------------------- + + async def _eligible(self, venue: str, adapter: Any, legs: list[ComboLeg], + given: list[ComboLeg]) -> tuple[list[ComboLeg], str | None, str]: + """The legs as the venue's combo takes them, the collection to build + it in (Kalshi), and how eligibility was decided. `_Ineligible` names + the first leg the venue will not take.""" + if callable(getattr(adapter, "fetch_combo_collections", None)): + return await self._eligible_collections(venue, adapter, legs, given) + if lists_combo_markets(adapter): + return await self._eligible_listed(venue, adapter, legs, given) + return legs, None, "unchecked: the venue lists no eligibility" + + async def _leg_event(self, adapter: Any, leg: ComboLeg) -> str: + if leg.event_id: + return ids.split(leg.event_id)[1] + reader = getattr(adapter, "_event_of", None) + if callable(reader): + return str(await reader(leg)) + ticker = ids.split(leg.market_id)[1] + return ticker.rsplit("-", 1)[0] + + async def _eligible_collections(self, venue: str, adapter: Any, legs: list[ComboLeg], + given: list[ComboLeg]) -> tuple[list[ComboLeg], str | None, str]: + events = [await self._leg_event(adapter, leg) for leg in legs] + legs = [leg.model_copy(update={"event_id": event}) for leg, event in zip(legs, events)] + wanted = list(dict.fromkeys(events)) + rows: list[dict[str, Any]] = [] + cursor: str | None = None + for _ in range(ELIGIBLE_PAGES): + page = await adapter.fetch_combo_collections(status="open", event_id=wanted[0], limit=200, cursor=cursor) + rows.extend(page) + cursor = page.next_cursor + if not cursor or not page: + break + fits: list[str] = [] + seen: set[str] = set() + for row in rows: + held = set(row.get("associated_event_tickers") or []) + seen |= held + low, high = row.get("size_min"), row.get("size_max") + # Kalshi lists `size_max` 0 (or none) for a collection with no maximum. + if set(wanted) <= held and (low is None or len(legs) >= int(low)) and \ + (high is None or int(high) <= 0 or len(legs) <= int(high)): + fits.append(str(row.get("collection_ticker"))) + if not fits: + if not rows: + raise _Ineligible(f"no open collection on {venue} takes event {wanted[0]}", leg=given[0].market_id, + code="outside_collections", + legs=[(given[0], "outside_collections", + f"event {wanted[0]} is in no open collection on {venue}", legs[0].market_id)]) + outside = [(leg, "outside_collections", f"event {event} is in no open collection on {venue} with event " + f"{wanted[0]}", mine.market_id) + for leg, mine, event in zip(given, legs, events) if event not in seen] + if outside: + raise _Ineligible(outside[0][2], leg=outside[0][0].market_id, code="outside_collections", legs=outside) + together = f"no open collection on {venue} takes these {len(legs)} legs together (events {', '.join(wanted)})" + raise _Ineligible(together, leg=given[-1].market_id, code="outside_collections", + legs=[(leg, "outside_collections", together, mine.market_id) + for leg, mine in zip(given, legs)], together=True) + fits = sorted(set(fits)) + how = f"collection {fits[0]}" + (f" (of {len(fits)}: {', '.join(fits)})" if len(fits) > 1 else "") + return legs, fits[0], how + + async def _eligible_listed(self, venue: str, adapter: Any, legs: list[ComboLeg], + given: list[ComboLeg]) -> tuple[list[ComboLeg], str | None, str]: + held = self._eligible_lists.get(venue) + now = time.monotonic() + if held is None or now - held[0] > ELIGIBLE_TTL_S: + held = (now, {}, False) + known, complete = dict(held[1]), held[2] + if not complete and any(leg.market_id not in known for leg in legs): + cursor: str | None = None + pages = 0 + while pages < ELIGIBLE_PAGES: + page = await adapter.fetch_combo_eligible_markets(cursor=cursor) + for row in page: + known[row.market_id] = row + pages += 1 + cursor = page.next_cursor + if not cursor: + complete = True + break + if all(leg.market_id in known for leg in legs): + break + self._eligible_lists[venue] = (now, known, complete) + out = [] + where = "is not on" if complete else f"is not in the first {ELIGIBLE_PAGES} pages of" + absent = [(original, "ineligible_leg", f"{leg.market_id} {where} {venue}'s combo-eligible list", leg.market_id) + for leg, original in zip(legs, given) if leg.market_id not in known] + if absent: + raise _Ineligible(absent[0][2], leg=absent[0][0].market_id, code="ineligible_leg", legs=absent) + for leg, original in zip(legs, given): + row = known[leg.market_id] + position = (row.position_ids or {}).get(leg.outcome) + if position and not leg.info.get("position_id"): + leg = leg.model_copy(update={"info": {**leg.info, "position_id": position}}) + if row.event_id and not leg.event_id: + leg = leg.model_copy(update={"event_id": row.event_id}) + out.append(leg) + return out, None, f"{venue}'s combo-eligible list" + + # -- quotes and ranking ---------------------------------------------------- + + async def refresh(self, group_id: str) -> RfqGroup: + """Read every open member's quotes from its venue now (the follower + does the same on its timer) and rank them.""" + group = await self.get(group_id) + for member in group.members: + if member.status != "open" or not member.rfq_id: + continue + adapter = self.engine.adapters.get(member.venue) + if adapter is None: + continue + try: + quotes = await adapter.fetch_quotes(member.rfq_id) + except Exception as exc: + log.info("synpath.engine: reading quotes of group %s at %s failed: %s", group.id, member.venue, exc) + continue + for quote in quotes: + await self.desk._observe(member.venue, quote, extra={"book": group.book}) + return group + + async def on_quote(self, venue: str, quote: RfqQuote) -> None: + """A quote on a member RFQ, new or changed (from the desk's + `_observe`): normalized, ranked, published.""" + group_id = self.group_of(venue, quote.rfq_id) + if group_id is None or group_id not in self.groups: + return + group = self.groups[group_id] + member = group.member_of(venue, quote.rfq_id) + if member is None: + return + entry = await self._normalize(group, member, quote) + group.quotes = [q for q in group.quotes if not (q.quote_id == entry.quote_id and q.venue == venue)] + [entry] + self._rank(group) + await self._save(group) + ranked = next(q for q in group.quotes if q.quote_id == entry.quote_id and q.venue == venue) + await self.engine.bus.publish("rfq_group.quote", {"group_id": group.id, **ranked.model_dump(mode="json")}, + key=f"rfq_group:{group.id}") + await self._publish("rfq_group.updated", group, reason="quote", quote_id=quote.id) + + async def _fee(self, venue: str, market_id: str, side: Side, price: Decimal, quantity: Decimal) -> Decimal | None: + """The venue's taker fee for one order of `quantity` at `price` (the + member's own YES side and price), from its fee schedule; `None` where + it will not say. Cached per price and size.""" + key = (venue, market_id, side.value, price, quantity) + if key in self._fees: + return self._fees[key] + adapter = self.engine.adapters.get(venue) + fee: Decimal | None = None + if adapter is not None and adapter.has.get("fetch_fee_estimate") and quantity > 0: + try: + estimate = await adapter.fetch_fee_estimate(market_id, side, price, quantity) + if estimate.taker_fee is not None: + fee = Decimal(str(estimate.taker_fee)) + except Exception as exc: # a fee the venue would not quote is not a reason to drop the quote + log.info("synpath.engine: no fee estimate for %s on %s: %s", market_id, venue, exc) + self._fees[key] = fee + return fee + + async def _normalize(self, group: RfqGroup, member: GroupMember, quote: RfqQuote) -> GroupQuote: + side_m = group.side if not member.flip else _opposite(group.side) + raw = quote.ask if side_m == Side.BUY else quote.bid + base = dict(quote_id=quote.id, venue=member.venue, rfq_id=quote.rfq_id, + market_id=quote.market_id or member.instrument_id, flip=member.flip, status=quote.status, + maker=(quote.info or {}).get("maker"), raw_bid=quote.bid, raw_ask=quote.ask, raw_price=raw, + **quote_expiry(quote, member)) + if raw is None: + return GroupQuote(**base, note="the maker declined the side asked") + price = raw if not member.flip else ONE - raw + token = outcome_price(price, group.outcome) + available = quote.ask_quantity if side_m == Side.BUY else quote.bid_quantity + market_id = base["market_id"] or "" + note = None + if group.quantity is not None: + quantity = group.quantity if available is None else min(group.quantity, available) + fee = await self._fee(member.venue, market_id, side_m, raw, quantity) + else: + quantity, fee, note = await self._size_amount(group, member, market_id, side_m, raw, token, available) + per = (fee or ZERO) / quantity if quantity else ZERO + effective = price + per if group.side == Side.BUY else price - per + effective_outcome = token + per if group.action == "buy" else token - per + partial = group.quantity is not None and available is not None and available < group.quantity + return GroupQuote(**base, price=price, outcome_price=token, available=available, quantity=quantity, + fee=fee, fee_per_contract=per if quantity else None, effective_price=effective, + effective_outcome_price=effective_outcome, partial=partial, note=note) + + async def _size_amount(self, group: RfqGroup, member: GroupMember, market_id: str, side_m: Side, raw: Decimal, + token: Decimal, available: Decimal | None) -> tuple[Decimal | None, Decimal | None, str | None]: + """The contracts `amount` buys from this quote after its taker fee + (a buy), or sells for (a sell, fees not deducted from the size).""" + amount = group.amount or ZERO + precision = self.desk._precision(member.venue, market_id) + step = ONE if precision.whole_contracts else (precision.quantity_step or FALLBACK_QUANTITY_STEP) + try: + quantity = contracts_for_amount(amount, token, precision, action=group.action) + except InvalidOrder as exc: + return None, None, str(exc) + if available is not None: + quantity = min(quantity, available) + fee = await self._fee(member.venue, market_id, side_m, raw, quantity) + if group.action == "buy": + for _ in range(6): + if quantity * token + (fee or ZERO) <= amount: + break + quantity = ((amount - (fee or ZERO)) / token / step).to_integral_value(rounding=ROUND_DOWN) * step + if quantity <= 0 or quantity < precision.min_quantity: + return None, None, f"{amount} does not cover the fee and the minimum size here" + fee = await self._fee(member.venue, market_id, side_m, raw, quantity) + return quantity, fee, None + + def _rank(self, group: RfqGroup) -> None: + """Best first: by contracts for the amount (an amount-sized buy), then + by `effective_outcome_price` (lowest for a buy, highest for a sell). + Quotes that cannot be accepted now (not open, their member gone, no + price for the side asked) come last, unranked.""" + open_members = {(m.venue, m.rfq_id) for m in group.members if m.status == "open"} + + def acceptable(q: GroupQuote) -> bool: + return (q.status == QuoteStatus.OPEN and q.effective_outcome_price is not None and q.quantity + and (q.venue, q.rfq_id) in open_members) or False + + buy = group.action == "buy" + + def order(q: GroupQuote) -> tuple: + price = q.effective_outcome_price or ZERO + by_price = price if buy else -price + if group.amount is not None and buy: + return (-(q.quantity or ZERO), by_price, q.venue, q.quote_id) + return (by_price, q.venue, q.quote_id) + + ranked = sorted((q for q in group.quotes if acceptable(q)), key=order) + rest = [q for q in group.quotes if not acceptable(q)] + for index, quote in enumerate(ranked, 1): + quote.rank = index + for quote in rest: + quote.rank = None + group.quotes = ranked + rest + group.expires_at = min((q.expires_at for q in ranked if q.expires_at is not None), default=None) + + # -- members closing -------------------------------------------------------- + + async def on_rfq_closed(self, venue: str, rfq_id: str, *, reason: str) -> None: + """A member RFQ closed at the venue (cancelled, expired or executed).""" + group_id = self.group_of(venue, rfq_id) + group = self.groups.get(group_id) if group_id else None + if group is None: + return + member = group.member_of(venue, rfq_id) + if member is None: + return + if reason == "executed": + member.status = "executed" if member.accept_key else "closed" + elif member.status in ("open", "creating"): + member.status = "canceled" if reason == "canceled" else "closed" + member.detail = member.detail or reason + if group.status == "open" and group.id not in self._busy \ + and not any(m.status in ("open", "creating") for m in group.members): + group.status = "expired" + self._rank(group) + await self._save(group) + await self._publish("rfq_group.updated", group, reason=f"member_{reason}", venue=venue) + + async def _cancel_member(self, group: RfqGroup, member: GroupMember, *, reason: str) -> bool: + """Close one member RFQ. A venue with no requester cancel + (`rfq_requester_cancel = False`, Polymarket global) is dropped here + and left to lapse at its expiry: nothing trades there unless accepted.""" + if member.status not in ("open", "creating") or not member.rfq_id: + return member.status not in ("open", "creating") + adapter = self.engine.adapters.get(member.venue) + lapses = adapter is not None and not getattr(adapter, "rfq_requester_cancel", True) + try: + await self.desk.cancel_rfq(member.rfq_id, venue=member.venue, reason=reason) + except Exception as exc: + member.detail = f"cancel failed: {type(exc).__name__}: {exc}" + group.failures.append({"venue": member.venue, "stage": "cancel", "error": member.detail, "ts": _now_ms()}) + await self._save(group) + return False + if lapses: + member.status = "lapsing" + member.detail = (f"{reason}: {member.venue} has no requester cancel; the RFQ lapses at its expiry and " + "nothing trades there unless accepted") + else: + member.status, member.detail = "canceled", reason + await self._save(group) + return True + + async def cancel_group(self, group_id: str, *, reason: str = "canceled by the caller") -> RfqGroup: + """Cancel every open member. A group already final is returned as it + is; one with an accept in flight is refused (its others are cancelled + already).""" + async with self.engine._lock(f"rfq-group-accept:{group_id}"): + group = await self.get(group_id) + if group.final: + return group + await self._reconcile(group) + if group.status in ("accepting", "accepted"): + raise RiskRejected(f"RFQ group {group_id} has an accept in flight on {group.accepted_venue}; " + "the other venues are cancelled already", rule="rfq_group_accept_in_flight") + self._busy.add(group.id) + try: + for member in group.members: + await self._cancel_member(group, member, reason=reason) + group.status = "canceled" + self._rank(group) + await self._save(group) + await self._publish("rfq_group.canceled", group, reason=reason) + finally: + self._busy.discard(group.id) + return group + + async def on_halt(self, scope: str = "*") -> dict[str, Any]: + """The kill switch: every open group's members in scope cancelled. A + group whose accept is in flight keeps it (the halt refuses any new + one); a group left with no open member is `canceled`.""" + out: dict[str, Any] = {} + for group in list(self.groups.values()): + if group.final: + continue + done = [] + self._busy.add(group.id) + try: + for member in group.members: + if scope in ("*", member.venue) and member.status in ("open", "creating"): + if await self._cancel_member(group, member, reason="halt"): + done.append(member.venue) + if group.status in ("open", "creating") and not any( + m.status in ("open", "creating") for m in group.members): + group.status = "canceled" + self._rank(group) + await self._save(group) + await self._publish("rfq_group.canceled" if group.status == "canceled" else "rfq_group.updated", + group, reason="halt") + finally: + self._busy.discard(group.id) + out[group.id] = {"status": group.status, "canceled": done} + return out + + # -- accept --------------------------------------------------------------- + + async def accept_group_quote(self, group_id: str, quote_id: str, *, venue: str | None = None, + worst_price: Decimal | None = None, wait: bool = False, timeout_s: float = 30.0, + tags: dict[str, str] | None = None) -> GroupAccept: + """Take one quote of the group (module doc): the claim, the other + members cancelled, then the accept through `RfqDesk.accept_quote`. + A quote is named by `(venue, quote_id)`: quote ids are the venues' + own, so two venues may use the same one. `venue` is required when the + group spans more than one venue (`BadRequest`, reason + `venue_required`). `worst_price` is in the group's YES terms. + Refused (`RiskRejected`, rule `rfq_group_accept_in_flight` or + `rfq_group_accepted`) while an accept on the group is in flight or + done.""" + await self.desk._lease() + async with self.engine._lock(f"rfq-group-accept:{group_id}"): + group = await self.get(group_id) + await self._reconcile(group) + self._refuse_taken(group) + if group.final: + raise BadRequest(f"RFQ group {group_id} is {group.status}") + venue = self._accept_venue(group, quote_id, venue) + entry = find_quote(group, venue, quote_id) + if entry is None: + await self.refresh(group_id) + entry = find_quote(group, venue, quote_id) + if entry is None: + raise RfqNotFound(f"no quote {quote_id!r} on {venue} in RFQ group {group_id}") + member = group.member_of(entry.venue, entry.rfq_id) + if member is None or member.status != "open": + raise BadRequest(f"quote {quote_id} is on {entry.venue}, whose RFQ in this group is " + f"{member.status if member else 'gone'}") + if entry.status != QuoteStatus.OPEN or not entry.quantity: + raise BadRequest(f"quote {quote_id} is {entry.status.value}" + + ("" if entry.quantity else f", with no size for this group ({entry.note})")) + accept = self._member_accept(group, member, entry, worst_price=worst_price, tags=tags) + # Everything the accept checks, before anything is written: a risk + # refusal here leaves every venue's RFQ standing. + await self.desk.check_accept(accept, venue=member.venue) + self._busy.add(group.id) + try: + claim = await self._claim(group, member, entry, accept) + return await self._accept_claimed(group, member, accept, claim, wait=wait, timeout_s=timeout_s) + finally: + self._busy.discard(group.id) + + @staticmethod + def _accept_venue(group: RfqGroup, quote_id: str, venue: str | None) -> str: + """The venue an accept names, or the group's only venue. A group on + several venues needs it: quote ids are the venues' own.""" + venues = sorted(group.venues()) + if venue is not None: + if venue not in venues: + exc = BadRequest(f"RFQ group {group.id} has no member on {venue} (its venues: {', '.join(venues)})") + exc.reason = "venue_not_in_group" + raise exc + return venue + if len(venues) == 1: + return venues[0] + holders = sorted({q.venue for q in group.quotes if q.quote_id == quote_id}) + what = (f"quote id {quote_id!r} is on {len(holders)} venues ({', '.join(holders)})" if len(holders) > 1 + else f"the group spans {', '.join(venues)}") + exc = BadRequest(f"name the quote's venue: {what}; accept takes {{quote_id, venue}}") + exc.reason = "venue_required" + raise exc + + def _refuse_taken(self, group: RfqGroup) -> None: + if group.status in ("accepting", "accepted"): + raise RiskRejected(f"RFQ group {group.id} has an accept in flight on {group.accepted_venue} " + f"(quote {group.accepted_quote_id}); a group trades once", + rule="rfq_group_accept_in_flight") + if group.status == "executed": + raise RiskRejected(f"RFQ group {group.id} was accepted on {group.accepted_venue} and executed; a group " + "trades once", rule="rfq_group_accepted") + + def _member_accept(self, group: RfqGroup, member: GroupMember, entry: GroupQuote, *, + worst_price: Decimal | None, tags: dict[str, str] | None) -> AcceptQuote: + adapter = self.engine.adapters[member.venue] + side_m = group.side if not member.flip else _opposite(group.side) + if getattr(adapter, "rfq_one_sided", False): + outcome, action = "yes", "buy" if side_m == Side.BUY else "sell" + else: + outcome = _flip_outcome(group.outcome) if member.flip else group.outcome + action = group.action + worst = None + if worst_price is not None: + worst = worst_price if not member.flip else ONE - worst_price + return AcceptQuote( + quote_id=entry.quote_id, rfq_id=entry.rfq_id, outcome=outcome, action=action, worst_price=worst, + quantity=entry.quantity, client_order_id=f"rfq-accept:{member.venue}:{entry.quote_id}", + book=group.book, trader=group.trader, tags={**group.tags, **(tags or {}), "rfq_group": group.id}) + + async def _claim(self, group: RfqGroup, member: GroupMember, entry: GroupQuote, accept: AcceptQuote) -> str: + """Hold the group for this quote, in the journal (module doc).""" + journal = self.engine.journal + attempt = 1 + while True: + key = f"rfq-group-accept:{group.id}:{attempt}" + prior = await journal.intent(key) + if prior is None: + break + if prior.state not in RELEASED: + raise RiskRejected(f"RFQ group {group.id} is held by accept {key} ({prior.state}); a group trades once", + rule="rfq_group_accepted" if prior.state in (IntentState.SENT, IntentState.SETTLED) + else "rfq_group_accept_in_flight") + attempt += 1 + others = [m.key for m in group.members if m is not member and m.status in ("open", "creating") and m.rfq_id] + account = self.desk._account(member.venue) + request = self.desk._record( + market_id=group.market_id, key=key, account=account, type=OrderType.RFQ_TAKER, side=group.side, + quantity=entry.quantity, price=entry.price, book=group.book, trader=group.trader, tags=group.tags, + params={"group_id": group.id, "operation": "rfq_group_accept", "quote_id": entry.quote_id, + "venue": member.venue, "rfq_id": entry.rfq_id, "member_accept_key": accept.client_order_id, + "cancel": others, "attempt": attempt}) + await journal.record_intent(request, client_order_id=key, venue=member.venue, account=account, + operation="rfq_group_accept", target_order_id=entry.quote_id) + stored = await journal.intent(key) + if stored is None or stored.target_order_id != entry.quote_id \ + or _params(stored).get("group_id") != group.id or _params(stored).get("venue") != member.venue: + raise RiskRejected(f"RFQ group {group.id}: another accept claimed it first", rule="rfq_group_accept_in_flight") + group.status, group.claim_key, group.accept_attempts = "accepting", key, attempt + group.accepted_quote_id, group.accepted_venue = entry.quote_id, member.venue + member.accept_key = accept.client_order_id + await self._save(group) + await self._publish("rfq_group.accepting", group, quote_id=entry.quote_id, venue=member.venue, claim_key=key) + return key + + async def _accept_claimed(self, group: RfqGroup, member: GroupMember, accept: AcceptQuote, claim: str, *, + wait: bool, timeout_s: float) -> GroupAccept: + journal = self.engine.journal + for other in group.members: + if other is not member: + await self._cancel_member(group, other, reason=f"accepted {accept.quote_id} on {member.venue}") + await journal.mark_intent(claim, IntentState.SENDING, detail="the others cancelled; accepting") + member.status = "accepting" + await self._save(group) + try: + quote = await self.desk.accept_quote(accept, venue=member.venue, _group=group.id) + except SubmissionInDoubt: + # The desk follows it; the group follows the desk (`_reconcile`). + await self._save(group) + raise + except Exception as exc: + await self._release(group, member, claim, detail=f"{type(exc).__name__}: {exc}") + raise + group.status, member.status = "accepted", "accepted" + await self._save(group) + await self._publish("rfq_group.accepted", group, quote_id=accept.quote_id, venue=member.venue) + if wait: + quote = await self.desk.wait(accept.client_order_id or "", venue=member.venue, timeout_s=timeout_s) + await self._reconcile(group) + return GroupAccept(group=group, quote=quote, venue=member.venue, client_order_id=accept.client_order_id or "", + claim_key=claim) + + async def _release(self, group: RfqGroup, member: GroupMember, claim: str, *, detail: str, + sent: bool = True) -> None: + """The accept did not trade: the claim is released and the group is + open again on what is still open. What was cancelled stays so.""" + await self.engine.journal.mark_intent(claim, IntentState.FAILED, detail=detail) + still = member.rfq_id is not None and f"{member.venue}:{member.rfq_id}" in self.desk.rfqs + member.status = "open" if still else "closed" + member.detail = f"accept failed: {detail}" + group.failures.append({"venue": member.venue, "stage": "accept", "quote_id": group.accepted_quote_id, + "claim_key": claim, "error": detail, "ts": _now_ms()}) + failed_quote = group.accepted_quote_id + for quote in group.quotes: + if sent and quote.quote_id == failed_quote and quote.venue == member.venue \ + and quote.status == QuoteStatus.OPEN: + quote.status = QuoteStatus.FAILED + group.accepted_quote_id = group.accepted_venue = None + group.status = "open" if any(m.status == "open" for m in group.members) else "expired" + self._rank(group) + await self._save(group) + await self._publish("rfq_group.accept_failed", group, claim_key=claim, venue=member.venue, + quote_id=failed_quote, error=detail) + + async def _reconcile(self, group: RfqGroup) -> str: + """Bring the group in line with the journal: members whose create was + in doubt, and the claim against its member accept's intent. Saved + only when something changed.""" + before = group.model_dump_json(exclude={"updated_at"}) + result = await self._reconcile_once(group) + if group.model_dump_json(exclude={"updated_at"}) != before: + await self._save(group) + return result + + async def _reconcile_once(self, group: RfqGroup) -> str: + journal = self.engine.journal + # While this process is creating the group's members, their state is + # its own: only a restarted engine reads them back from the journal. + for member in group.members if group.id not in self._busy or group.status != "creating" else []: + if member.status == "creating" and not member.create_key: + # The fan-out never reached it (the process stopped first). + member.status, member.detail = "failed", "not created: the process stopped before its turn" + continue + if member.status == "creating" and member.create_key: + intent = await journal.intent(member.create_key) + if intent is None: + member.status, member.detail = "failed", "the create was never written: nothing was sent" + elif intent.state in (IntentState.SENT, IntentState.SETTLED) and intent.order_id: + held = self.desk.rfqs.get(f"{member.venue}:{intent.order_id}") + rfq = Rfq.model_validate(held["rfq"]) if held else None + if rfq is not None: + self._member_open(group, member, rfq) + if group.status not in ("open", "creating"): + await self._cancel_member(group, member, reason=f"the group is {group.status}") + else: + member.rfq_id, member.status = intent.order_id, "closed" + elif intent.state in RELEASED: + member.status, member.detail = "failed", intent.detail or intent.state + if group.status == "creating": + group.status = "open" if any(m.status in ("open", "creating") for m in group.members) else "failed" + if group.status not in ("accepting", "accepted") or not group.claim_key: + if group.status == "open" and not any(m.status in ("open", "creating") for m in group.members): + group.status = "expired" + await self._publish("rfq_group.updated", group, reason="expired") + return "unchanged" + claim = await journal.intent(group.claim_key) + if claim is None: + return "unchanged" + params = _params(claim) + member = group.member_of(str(params.get("venue")), str(params.get("rfq_id"))) + if member is None: + return "unchanged" + accept_key = params.get("member_accept_key") or member.accept_key + accepted = await journal.intent(accept_key) if accept_key else None + if claim.state in RELEASED: + if group.status in ("accepting", "accepted"): + await self._release(group, member, claim.client_order_id, detail=claim.detail or claim.state) + return "released" + if accepted is None or accepted.state == IntentState.PLANNED: + # The process stopped between the claim and the accept: finish the + # cancels it planned, release it. Nothing was sent. + for key in params.get("cancel") or []: + venue, _, rfq_id = str(key).partition(":") + other = group.member_of(venue, rfq_id) + if other is not None: + await self._cancel_member(group, other, reason=f"accepted {params.get('quote_id')} on " + f"{params.get('venue')} (finished after a restart)") + if accepted is not None: + await journal.mark_intent(accept_key, IntentState.REJECTED, detail="never sent: the process stopped") + await self._release(group, member, claim.client_order_id, + detail="the process stopped before the accept was sent; nothing traded", sent=False) + return "released" + if accepted.state in (IntentState.SENT, IntentState.SETTLED): + group.status, member.status, group.order_id = "executed", "executed", accepted.order_id + await journal.mark_intent(claim.client_order_id, IntentState.SENT, order_id=accepted.order_id, + detail="executed") + self._rank(group) + await self._save(group) + await self._publish("rfq_group.executed", group, order_id=accepted.order_id, venue=member.venue) + return "executed" + if accepted.state in RELEASED: + await self._release(group, member, claim.client_order_id, + detail=f"the accept ended without a trade: {accepted.detail or accepted.state}") + return "released" + if claim.state == IntentState.PLANNED: + await journal.mark_intent(claim.client_order_id, IntentState.SENDING, detail="accept sent; following") + if group.status == "accepting" and accepted.detail == self._accepted_mark(): + group.status, member.status = "accepted", "accepted" + return "pending" + + @staticmethod + def _accepted_mark() -> str: + from .rfq import ACCEPTED_MARK + return ACCEPTED_MARK + + # -- the follower and recovery ---------------------------------------------- + + async def tick(self) -> None: + """One pass: every group not final reconciled with the journal.""" + for group in list(self.groups.values()): + if group.final or group.id in self._busy: + continue + try: + await self._reconcile(group) + except Exception: + log.exception("synpath.engine: following RFQ group %s failed", group.id) + + async def restore(self) -> None: + """At start, after the desk's own restore: the groups not final, and + any member RFQ the desk's working set lacks re-seeded at its venue.""" + raw = await self.engine.journal.cursor(GROUPS_CURSOR) + reseed: dict[str, list[Rfq]] = {} + for row in json.loads(raw) if raw else []: + try: + group = RfqGroup.model_validate(row) + except Exception as exc: + log.warning("synpath.engine: an RFQ group in the journal does not validate: %s", exc) + continue + self.groups[group.id] = group + for member in group.members: + if not member.rfq_id: + continue + self._by_rfq[member.key] = group.id + if member.status in ("open", "accepting", "accepted") and member.rfq \ + and member.key not in self.desk.rfqs: + self.desk.rfqs[member.key] = {"rfq": member.rfq, "book": group.book, "trader": group.trader} + try: + reseed.setdefault(member.venue, []).append(Rfq.model_validate(member.rfq)) + except Exception as exc: + log.warning("synpath.engine: member RFQ %s does not validate: %s", member.key, exc) + for venue, rfqs in reseed.items(): + adapter = self.engine.adapters.get(venue) + restore = getattr(adapter, "restore_rfqs", None) + if callable(restore): + try: + restore(rfqs, []) + except Exception as exc: + log.warning("synpath.engine: re-seeding group RFQs at %s failed: %s", venue, exc) + await self.desk._save_working_set(venue) + + async def reconcile_all(self) -> list[dict[str, Any]]: + """After recovery's in-doubt pass: every group reconciled.""" + out = [] + for group in list(self.groups.values()): + if group.final: + continue + out.append({"group_id": group.id, "result": await self._reconcile(group), "status": group.status}) + return out + + async def resolve(self, intent: Intent) -> dict[str, Any]: + """A group intent in doubt (`Engine._resolve`): the create once its + members are known, the claim against its member accept.""" + base = {"client_order_id": intent.client_order_id, "venue": intent.venue, "operation": intent.operation} + group_id = _params(intent).get("group_id") + group = self.groups.get(str(group_id)) if group_id else None + if group is None: + await self.engine.journal.mark_intent(intent.client_order_id, IntentState.LOST, + detail="no such group in the journal's working set") + return base | {"result": "swept"} + if intent.operation == "rfq_group_create": + await self._reconcile(group) + standing = [m for m in group.members if m.status != "failed"] + await self.engine.journal.mark_intent( + intent.client_order_id, IntentState.SENT if standing else IntentState.REJECTED, order_id=group.id, + detail=f"recovered with {len(standing)} of {len(group.members)} members") + if not standing: + group.status = "failed" + await self._save(group) + return base | {"result": "adopted" if standing else "swept", "group_id": group.id} + result = await self._reconcile(group) + return base | {"result": {"executed": "adopted", "released": "swept"}.get(result, "pending"), + "group_id": group.id} + + +def quote_expiry(quote: RfqQuote, member: GroupMember) -> dict[str, Any]: + """A quote's deadlines as its venue gives them: its own expiry, else its + RFQ's; none is made up where the venue gives none.""" + rfq_expiry = (member.rfq or {}).get("expires_at") + expires = quote.expires_at if quote.expires_at is not None else rfq_expiry + source = "quote" if quote.expires_at is not None else "rfq" if rfq_expiry is not None else None + return {"expires_at": int(expires) if expires is not None else None, "expires_from": source, + "confirm_by": quote.confirm_by, "execute_by": quote.execute_by} + + +def find_quote(group: RfqGroup, venue: str, quote_id: str) -> GroupQuote | None: + """A group's quote by `(venue, quote_id)`.""" + return next((q for q in group.quotes if q.venue == venue and q.quote_id == quote_id), None) + + +class _Ineligible(Exception): + """The venue's combos do not take these legs: each `(given leg, code, + detail, the venue's market)`, the code `ineligible_leg` or + `outside_collections`; `together` when each is in a collection, but no + one collection takes them all.""" + + def __init__(self, message: str, *, leg: str | None = None, code: str = "ineligible_leg", + legs: list[tuple[ComboLeg, str, str, str | None]] | None = None, together: bool = False): + super().__init__(message) + self.leg, self.code, self.legs, self.together = leg, code, list(legs or []), together + + +def skipped_combo_venue(venue: str, lacking: list[tuple[ComboLeg, str, str, str | None]], + labels: Mapping[str, str], *, together: bool = False) -> SkippedVenue: + """A combo venue skipped for the legs it lacks: each leg by its label, + with its reason, and one sentence naming them all.""" + missing = [SkippedLeg(market_id=leg.market_id, label=labels.get(leg.market_id) or leg.market_id, code=code, + detail=detail or None, mapped_id=mapped) + for leg, code, detail, mapped in lacking] + codes = [c for c in LEG_CODES if any(m.code == c for m in missing)] or [missing[0].code] + return SkippedVenue(venue=venue, code=codes[0], reason=skip_reason(venue, missing, together=together), + leg=missing[0].market_id, missing_legs=missing, + detail="; ".join(dict.fromkeys(m.detail for m in missing if m.detail)) or None) + + +def skip_reason(venue: str, missing: list[SkippedLeg], *, together: bool = False) -> str: + """`no match for “Al Fayha to win”, “U.S. invades Iran”`, one clause per + reason, in `LEG_CODES` order.""" + name = VENUE_NAMES.get(venue, venue) + + def quoted(code: str) -> str: + return ", ".join(f"“{m.label}”" for m in missing if m.code == code) + + clauses = [] + for code in [*LEG_CODES, *sorted({m.code for m in missing} - set(LEG_CODES))]: + if not any(m.code == code for m in missing): + continue + if code == "missing_leg": + clauses.append(f"no match for {quoted(code)}") + elif code == "matching_error": + clauses.append(f"matching unavailable for {quoted(code)}") + elif code == "ineligible_leg": + clauses.append(f"not combo-eligible on {name}: {quoted(code)}") + elif code == "outside_collections" and together: + clauses.append(f"no open {name} combo collection takes {quoted(code)} together") + elif code == "outside_collections": + scope = COLLECTION_SCOPES.get(venue, f"{name} combos take only the events of its open collections") + clauses.append(f"{scope}: {quoted(code)} is in none") + else: + clauses.append(f"{code.replace('_', ' ')}: {quoted(code)}") + return "; ".join(clauses) + + +def _params(intent: Intent) -> dict[str, Any]: + """An intent's request parameters, as stored.""" + request = intent.request or {} + return dict(request.get("params") or {}) + + +__all__ = ["GroupAccept", "GroupInstrument", "GroupMember", "GroupQuote", "Matcher", "RfqGroup", "RfqGroupDesk", + "SkippedVenue", "hosted_matcher", "lists_combo_markets"] diff --git a/synpath/engine/risk.py b/synpath/engine/risk.py index 24fd204..511a385 100644 --- a/synpath/engine/risk.py +++ b/synpath/engine/risk.py @@ -45,7 +45,7 @@ from pydantic import BaseModel, ConfigDict, Field from .. import ids -from ..trading.types import Account, Order, OrderRequest, Side +from ..trading.types import Account, Order, OrderRequest, OrderType, Side from .ledger import Ledger ZERO = Decimal("0") @@ -63,37 +63,37 @@ class RiskConfig(BaseModel): enabled: bool = True # -- this order -- + # How far from the mark a limit may sit, in price units (contracts are + # priced 0 to 1, so 0.10 is ten cents). `None` disables the check. price_collar: Decimal | None = Decimal("0.10") - """How far from the mark a limit may sit, in price units (contracts are - priced 0 to 1, so 0.10 is ten cents). `None` disables the check.""" max_order_contracts: Decimal | None = None + # Priced outcome cost times contracts, in account currency, excluding fees. max_order_notional: Decimal | None = None - """Price times contracts, in account currency.""" min_price: Decimal = Decimal("0") max_price: Decimal = Decimal("1") + # The same instrument, side, price and size within this window is a retry. duplicate_window_ms: int = 2_000 - """The same instrument, side, price and size within this window is a retry.""" + # Refuse opening orders this close to a market's resolution time. closing_soon_s: int | None = 60 - """Refuse opening orders this close to a market's resolution time.""" + # Instrument or market ids, or prefixes ending in `*`, nobody may trade. restricted: list[str] = Field(default_factory=list) - """Instrument or market ids, or prefixes ending in `*`, nobody may trade.""" # -- the book -- max_open_orders: int | None = 200 + # Per instrument, per account, netted across books. max_position_contracts: Decimal | None = None - """Per instrument, per account, netted across books.""" max_event_contracts: Decimal | None = None max_venue_notional: Decimal | None = None + # Realized loss since the day's start, as a positive number. daily_loss_limit: Decimal | None = None - """Realized loss since the day's start, as a positive number.""" max_orders_per_minute: int | None = 120 # -- the firm -- self_trade_prevention: SelfTradeScope = "firm" + # Venue-published position limits, per market id. exchange_limits: dict[str, Decimal] = Field(default_factory=dict) - """Venue-published position limits, per market id.""" + # Strategies that may cancel but not open. paused_books: list[str] = Field(default_factory=list) - """Strategies that may cancel but not open.""" def paused(self, book: str | None) -> bool: return bool(book and book in self.paused_books) @@ -132,8 +132,8 @@ class KillSwitch: """Stop trading now, and say what happens to what is already resting.""" engaged: bool = False + # `*` for everything, or a venue id. scope: str = "*" - """`*` for everything, or a venue id.""" policy: HaltPolicy = "cancel" reason: str = "" engaged_ts: int | None = None @@ -212,24 +212,25 @@ def realized_today(self) -> Decimal: # -- the check ------------------------------------------------------------ - def check( + def evaluate_order_risk( self, request: OrderRequest, *, - venue: str, - account: Account, + venue: str | None, + account: Account | None, open_orders: Iterable[Order] = (), - mark: Decimal | None = None, + risk_reference_price: Decimal | None = None, market_close_ts: int | None = None, event_id: str | None = None, venue_notional: Decimal | None = None, - deliberate: bool = False, + caller_supplied_order_id: bool = False, + instruction_only: bool = False, ) -> Decision: """Everything that must be true before this order is signed. - `deliberate` is true when the caller named the order's - `client_order_id` itself: the duplicate window is for an accidental - second click, and a caller that names each order is not clicking.""" + `risk_reference_price` is the reference for the limit-price risk check. + `caller_supplied_order_id` skips the accidental-duplicate check when true; + retries with the same ID remain subject to the engine's submission checks.""" config = self.config if not config.enabled: return self._ok() @@ -246,16 +247,26 @@ def check( if price is not None: if not (config.min_price <= price <= config.max_price): return self._no("price_bounds", f"price {price} is outside {config.min_price}-{config.max_price}") - if config.price_collar is not None and mark is not None: - away = abs(price - mark) + # The collar is for a limit a caller sends. A market order's price + # is only its protection bound (Kalshi's 0.99 / 0.01), and an + # engine-held order's child (a stop that fired) trades at the + # market on purpose: neither is a quote to check against the risk reference price. + collared = request.type == OrderType.LIMIT and not request.tags.get("parent") + if config.price_collar is not None and risk_reference_price is not None and collared: + away = abs(price - risk_reference_price) if away > config.price_collar: return self._no( - "price_collar", f"price {price} is {away} from the mark {mark}, over the collar {config.price_collar}", - detail={"mark": str(mark), "away": str(away)}, + "price_collar", f"price {price} is {away} from the mark {risk_reference_price}, over the collar {config.price_collar}", + detail={"mark": str(risk_reference_price), "away": str(away)}, ) if config.max_order_contracts is not None and request.quantity > config.max_order_contracts: return self._no("max_order_contracts", f"{request.quantity} contracts is over the {config.max_order_contracts} limit") - notional = (price or ONE) * request.quantity + # Prices are always YES prices; a contract is worth its outcome's + # price, YES at the price and NO at the complement, bought or sold. + value_per_contract = ONE + if price is not None: + value_per_contract = price if request.leg[0] == "yes" else ONE - price + notional = value_per_contract * request.quantity if config.max_order_notional is not None and notional > config.max_order_notional: return self._no("max_order_notional", f"{notional} is over the {config.max_order_notional} order limit") @@ -263,7 +274,7 @@ def check( # An engine-held order's children are sliced on purpose and often look # alike (an iceberg's reloads, a TWAP's slices, two parents on one # market), so the window applies to orders sent from outside only. - if config.duplicate_window_ms and not deliberate and not request.tags.get("parent"): + if config.duplicate_window_ms and not caller_supplied_order_id and not request.tags.get("parent"): signature = (market_id, request.side.value, str(price), str(request.quantity)) cutoff = now - config.duplicate_window_ms / 1000 for stamp, seen in reversed(self._recent): @@ -280,7 +291,13 @@ def check( if len(self._sent) >= config.max_orders_per_minute: return self._no("order_rate", f"{len(self._sent)} orders in the last minute, at the {config.max_orders_per_minute} limit") - if config.closing_soon_s is not None and market_close_ts is not None and not request.reduce_only: + if instruction_only: + if config.daily_loss_limit is not None and self.ledger is not None: + if -self.realized_today() >= config.daily_loss_limit: + return self._no("daily_loss", "daily realized loss limit reached") + return self._ok() + + if config.closing_soon_s is not None and market_close_ts is not None and request.leg[1] != "sell": left = (market_close_ts - now * 1000) / 1000 if left <= config.closing_soon_s: return self._no( @@ -335,6 +352,35 @@ def check( ) return self._ok() + def check_offer( + self, + market_id: str, + *, + venue: str, + book: str | None = None, + quantity: Decimal | None = None, + notional: Decimal | None = None, + ) -> Decision: + """The rules for an offer that commits nothing yet: an RFQ asked or a + maker quote sent (docs/rfq-combo.md §5). Halted, a paused book, the + restricted list, and the order size limits on the size the offer + could trade. The rest (positions, the daily loss) apply when it + trades: an accept or a confirm goes through `check` like an order.""" + config = self.config + if not config.enabled: + return self._ok() + if self.kill.blocks(venue, now=self.clock()): + return self._no("kill_switch", f"trading is halted: {self.kill.reason or 'kill switch engaged'}") + if config.paused(book): + return self._no("book_paused", f"strategy {book!r} is paused; it may cancel but not open") + if (pattern := config.restricts(market_id)): + return self._no("restricted", f"{market_id} matches the restricted entry {pattern!r}") + if quantity is not None and config.max_order_contracts is not None and quantity > config.max_order_contracts: + return self._no("max_order_contracts", f"{quantity} contracts is over the {config.max_order_contracts} limit") + if notional is not None and config.max_order_notional is not None and notional > config.max_order_notional: + return self._no("max_order_notional", f"{notional} is over the {config.max_order_notional} order limit") + return self._ok() + # -- bookkeeping ---------------------------------------------------------- def record_sent(self, request: OrderRequest) -> None: diff --git a/synpath/engine/router.py b/synpath/engine/router.py index 323cef1..95f925d 100644 --- a/synpath/engine/router.py +++ b/synpath/engine/router.py @@ -43,14 +43,14 @@ from ..types import OrderBook ZERO = Decimal("0") +# `fee(market_id, price, contracts)` -> the taker fee for that many contracts +# at that price, in the venue's currency. The plan asks per level. FeeFn = Callable[[str, Decimal, Decimal], Decimal] -"""`fee(market_id, price, contracts)` -> the taker fee for that many contracts -at that price, in the venue's currency. The plan asks per level.""" Reason = Literal["", "worst_price", "liquidity", "min_quantity"] +# Per market id, the least one taker order pays. Absent means no floor. FeeFloors = Mapping[str, Decimal] -"""Per market id, the least one taker order pays. Absent means no floor.""" PriceSize = tuple[Decimal, Decimal] @@ -117,10 +117,10 @@ def default_precision(market_id: str) -> Precision: @dataclass(frozen=True) class Level: market_id: str + # In bucket terms: what one contract of the bucket's YES costs (asks) or fetches (bids). price: Decimal - """In bucket terms: what one contract of the bucket's YES costs (asks) or fetches (bids).""" + # `price` plus the taker fee per contract on a buy, minus it on a sell. net_price: Decimal - """`price` plus the taker fee per contract on a buy, minus it on a sell.""" size: Decimal @@ -128,20 +128,20 @@ class Level: class Leg: market_id: str flip: bool + # The member's own side, after `flip`. side: Side - """The member's own side, after `flip`.""" + # The member's own YES price, after `flip` and tick rounding. What the venue receives. price: Decimal - """The member's own YES price, after `flip` and tick rounding. What the venue receives.""" quantity: Decimal + # The worst bucket-terms price this leg reaches, before fees. bucket_price: Decimal - """The worst bucket-terms price this leg reaches, before fees.""" + # Volume-weighted net price of the levels this leg takes, in bucket terms, + # with the venue's fee floor included where it binds. net_price: Decimal - """Volume-weighted net price of the levels this leg takes, in bucket terms, - with the venue's fee floor included where it binds.""" + # The taker fee this leg is expected to pay, floor included. fee: Decimal = ZERO - """The taker fee this leg is expected to pay, floor included.""" + # How much of `fee` is the floor over what the levels' fees add up to. fee_floor: Decimal = ZERO - """How much of `fee` is the floor over what the levels' fees add up to.""" def notional(self) -> Decimal: """What the token this leg buys costs: YES at `price` on a buy, NO at @@ -176,10 +176,11 @@ def expected_net_price(self) -> Decimal | None: def merge(bucket: Bucket, books: Mapping[str, Any], fee: FeeFn) -> tuple[list[Level], list[Level]]: """The members' books as one, in bucket terms: `(asks, bids)`, asks cheapest-net first, bids richest-net first. Books go through `view`, so a - flipped member may hand in either side.""" + flipped member may hand in either side. A member switched off + (`enabled=False`) is left out, whatever book is handed in for it.""" asks: list[Level] = [] bids: list[Level] = [] - for member in bucket.members: + for member in bucket.enabled_members(): book = books.get(member.market_id) if book is None: continue @@ -247,7 +248,7 @@ def _native_walk( """The walk on the Rust core: the side being planned merged there once, then walked once per call. `None` to plan in Python.""" rows = [] - for member in bucket.members: + for member in bucket.enabled_members(): book = books.get(member.market_id) if book is None: continue diff --git a/synpath/engine/storage.py b/synpath/engine/storage.py new file mode 100644 index 0000000..ed7730b --- /dev/null +++ b/synpath/engine/storage.py @@ -0,0 +1,180 @@ +"""Storage boundary for execution, independent of database SQL. + +SQLite and PostgreSQL implement the same transaction and recovery contract. +Each backend owns its connection, schema initialization and execution lease. +A deployment selects exactly one store. +""" +from __future__ import annotations + +from decimal import Decimal +from typing import Any, AsyncIterator, Iterable, Protocol, runtime_checkable + +from ..trading.types import Account, Fill, Order, OrderRequest +from .storage_types import Intent, JournalEvent, Lease + + +@runtime_checkable +class JournalStore(Protocol): + owner: str + path: str + user_id: str + lease: Lease | None + async def __aenter__(self) -> JournalStore: + ... + + async def __aexit__(self, *exc: Any) -> None: + ... + + async def open(self) -> 'JournalStore': + ... + + @property + def is_open(self) -> bool: + ... + + async def typed_order(self, kind: str, venue: str, application_id: str) -> dict[str, Any] | None: + ... + + async def lease_records(self) -> list[dict[str, Any]]: + ... + + async def schema_version(self) -> int: + ... + + async def close(self) -> None: + ... + + async def append(self, kind: str, payload: dict[str, Any] | None=None, *, key: str | None=None) -> int: + ... + + def replay(self, since: int=0, *, kinds: Iterable[str] | None=None, limit: int | None=None) -> AsyncIterator[JournalEvent]: + ... + + async def last_seq(self) -> int: + ... + + async def submission(self, client_order_id: str) -> dict[str, Any] | None: + ... + + async def record_submission(self, original: OrderRequest, effective: OrderRequest, + *, venue: str, account: Account | None) -> None: + ... + + async def reject_submission(self, client_order_id: str, error: dict[str, Any]) -> None: + ... + + async def plan_managed_child(self, snapshot: dict[str, Any], request: OrderRequest, + *, venue: str, account: Account, parent_order: Order | None = None) -> None: + ... + + async def record_intent(self, request: OrderRequest, *, client_order_id: str, venue: str, account: Account, operation: str='create', target_order_id: str | None=None) -> Intent: + ... + + async def mark_intent(self, client_order_id: str, state: str, *, order_id: str | None=None, detail: str | None=None, bump_attempt: bool=False) -> None: + ... + + async def intent(self, client_order_id: str) -> Intent | None: + ... + + async def intents(self, *, state: str | None=None, states: Iterable[str] | None=None, older_than_ms: int | None=None) -> list[Intent]: + ... + + async def in_doubt(self, older_than_ms: int=0) -> list[Intent]: + ... + + async def upsert_order(self, order: Order, *, event: str='order.updated') -> None: + ... + + async def record_fill(self, fill: Fill, *, book: str | None=None, trader: str | None=None) -> bool: + ... + + async def order(self, venue: str | None, order_id: str) -> Order | None: + ... + + async def order_by_client_id(self, client_order_id: str) -> Order | None: + ... + + async def open_orders(self, *, venue: str | None=None, account_key: str | None=None, book: str | None=None) -> list[Order]: + ... + + async def events_page(self, *, kinds: Iterable[str]=(), prefixes: Iterable[str]=(), + keys: Iterable[str] | None=None, before: int | None=None, + limit: int=500) -> list[JournalEvent]: + ... + + async def orders_page(self, *, venue: str | None=None, book: str | None=None, market_id: str | None=None, + since_ts: int | None=None, before: tuple[int, str, str] | None=None, + open_only: bool=False, limit: int | None=100) -> list[tuple[Order, tuple[int, str, str]]]: + ... + + async def fills(self, *, since_ts: int | None=None, venue: str | None=None, book: str | None=None) -> list[Fill]: + ... + + async def has_fill(self, venue: str, fill_id: str) -> bool: + ... + + async def orders_for_parent(self, parent_id: str) -> list[Order]: + ... + + async def save_bucket(self, bucket: dict[str, Any]) -> None: + ... + + async def bucket(self, bucket_id: str) -> dict[str, Any] | None: + ... + + async def buckets(self, *, book: str | None=None, status: str | None='active') -> list[dict[str, Any]]: + ... + + async def archive_bucket(self, bucket_id: str) -> bool: + ... + + async def save_managed(self, snapshot: dict[str, Any], *, order: Order | None = None) -> None: + ... + + async def managed(self, *, states: Iterable[str] | None=None) -> list[dict[str, Any]]: + ... + + async def managed_on(self, market_id: str) -> list[tuple[str, dict[str, Any]]]: + ... + + async def routed_account_keys(self, venue: str) -> list[str]: + ... + + async def managed_one(self, parent_id: str) -> tuple[str, dict[str, Any]] | None: + ... + + async def set_cursor(self, name: str, value: Any) -> None: + ... + + async def cursor(self, name: str, default: Any=None) -> Any: + ... + + async def save_config(self, kind: str, payload: dict[str, Any], *, author: str | None=None) -> int: + ... + + async def config(self, kind: str, version: int | None=None) -> tuple[int, dict[str, Any]] | None: + ... + + async def acquire_lease(self, name: str='engine', *, ttl_ms: int=30000, steal_expired: bool=True) -> Lease: + ... + + async def renew_lease(self) -> bool: + ... + + async def holds_lease(self) -> bool: + ... + + async def release_lease(self) -> None: + ... + + +def create_journal(*, backend: str = "sqlite", path: str = "synpath.db", + user_id: str = "local-user", schema: str = "public") -> JournalStore: + """Reject unsupported backends instead of silently using a local file.""" + if backend == "postgresql": + from .postgres import PostgresJournal + return PostgresJournal(path, user_id=user_id, schema=schema) + if backend != "sqlite": + raise ValueError(f"storage backend {backend!r} is not available; choose sqlite or postgresql") + from .journal import Journal + return Journal(path, user_id=user_id) diff --git a/synpath/engine/storage_types.py b/synpath/engine/storage_types.py new file mode 100644 index 0000000..a32c914 --- /dev/null +++ b/synpath/engine/storage_types.py @@ -0,0 +1,68 @@ +"""Database-independent journal records and execution ownership errors.""" +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from typing import Any + + +class LeaseLost(RuntimeError): + """Another engine holds the journal, or this one's lease expired.""" + + +class IntentState: + # Written, risk passed, not yet sent. + PLANNED = "planned" + # Handed to the venue; the answer has not come back. + SENDING = "sending" + SENT = "sent" + # The order reached a terminal status. + SETTLED = "settled" + REJECTED = "rejected" + # The venue refused it, or the call failed in a way that proves nothing left. + FAILED = "failed" + # In doubt for longer than the sweep allows and not found at the venue. + LOST = "lost" + + +@dataclass(frozen=True, slots=True) +class JournalEvent: + seq: int + ts: int + kind: str + key: str | None + payload: dict[str, Any] + + +@dataclass(slots=True) +class Intent: + client_order_id: str + state: str + operation: str + venue: str + account_key: str + market_id: str | None + order_id: str | None + target_order_id: str | None + book: str | None + trader: str | None + attempts: int + request: dict[str, Any] + detail: str | None + created_ts: int + updated_ts: int + + @property + def age_ms(self) -> int: + return int(time.time() * 1000) - self.updated_ts + + +@dataclass(slots=True) +class Lease: + name: str + owner: str + holder: str + acquired_ts: int + expires_ts: int + ttl_ms: int = 30_000 + renew_task: Any = field(default=None, repr=False) diff --git a/synpath/engine/submissions.py b/synpath/engine/submissions.py new file mode 100644 index 0000000..b44e341 --- /dev/null +++ b/synpath/engine/submissions.py @@ -0,0 +1,20 @@ +"""Retry identity, independent of storage. The unresolved-action errors live +with the other trading errors and are re-exported here.""" +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +from ..trading.errors import CancelUnconfirmed, SubmissionInDoubt, SubmissionPending +from ..trading.types import OrderRequest + +__all__ = ["CancelUnconfirmed", "SubmissionInDoubt", "SubmissionPending", "request_identity"] + + +def request_identity(request: OrderRequest) -> dict[str, Any]: + """Compare instructions rather than the spelling of their decimal values.""" + body = request.model_dump(mode="json") + for name in ("quantity", "price", "stop_price"): + if body[name] is not None: + body[name] = format(Decimal(body[name]).normalize(), "f") + return body diff --git a/synpath/errors.py b/synpath/errors.py index c03b6a9..46d2602 100644 --- a/synpath/errors.py +++ b/synpath/errors.py @@ -47,8 +47,8 @@ class RateLimitExceeded(NetworkError): def __init__(self, message: str, *, retry_after: float | None = None): super().__init__(message) + # Seconds to wait, when the venue said. Kalshi does not, so usually None. self.retry_after = retry_after - """Seconds to wait, when the venue said. Kalshi does not, so usually None.""" class ExchangeError(SynpathError): diff --git a/synpath/hyperliquid.py b/synpath/hyperliquid.py index 594da20..cd7234b 100644 --- a/synpath/hyperliquid.py +++ b/synpath/hyperliquid.py @@ -56,45 +56,45 @@ TESTNET_INFO_URL = "https://api.hyperliquid-testnet.xyz" VENUE = "hyperliquid" +# The venue weighs info requests (a book 2, a catalog 20) against 1200 a +# minute per IP. Five a second keeps a burst of book reads inside it; the +# heavy catalog reads are cached, not repeated. LIMITER = RateLimiter(5.0, burst=10) -"""The venue weighs info requests (a book 2, a catalog 20) against 1200 a -minute per IP. Five a second keeps a burst of book reads inside it; the -heavy catalog reads are cached, not repeated.""" +# Seconds a read of the catalog and the 24h figures is reused. Outcomes are +# listed and settled on the scale of minutes, and every call here starts from +# the catalog, so re-reading it per call would spend the rate budget on the +# same document. CATALOG_TTL = 30.0 -"""Seconds a read of the catalog and the 24h figures is reused. Outcomes are -listed and settled on the scale of minutes, and every call here starts from -the catalog, so re-reading it per call would spend the rate budget on the -same document.""" +# Seconds the template texts are reused: they change only when the venue +# adds a template. TEMPLATES_TTL = 3600.0 -"""Seconds the template texts are reused: they change only when the venue -adds a template.""" BASE_TAKER_RATE = 0.0007 +# HyperCore's spot fees at the lowest volume tier, which outcome trading +# uses. A higher tier pays less; the account's own tier is not known here. BASE_MAKER_RATE = 0.0004 -"""HyperCore's spot fees at the lowest volume tier, which outcome trading -uses. A higher tier pays less; the account's own tier is not known here.""" +# The venue's minimum order value, in the quote token. MIN_NOTIONAL = 10.0 -"""The venue's minimum order value, in the quote token.""" +# Bars looked back when neither `since` nor `limit` bounds the window. DEFAULT_BARS = 100 -"""Bars looked back when neither `since` nor `limit` bounds the window.""" +# Bar widths `candleSnapshot` serves. A timeframe this library offers that +# the venue does not (6h) is built from the widest one that divides it. CANDLE_INTERVALS = ("1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d") -"""Bar widths `candleSnapshot` serves. A timeframe this library offers that -the venue does not (6h) is built from the widest one that divides it.""" +# Marks a question's id (`q198`), so it cannot be read as an outcome's. QUESTION_PREFIX = "q" -"""Marks a question's id (`q198`), so it cannot be read as an outcome's.""" +# Fields that say when the thing a market is about happens, in the order +# they are preferred as its close time. TIME_FIELDS = ("time", "expiry", "scheduledStart", "scheduledDecision", "dateTime", "listingDeadline") -"""Fields that say when the thing a market is about happens, in the order -they are preferred as its close time.""" +# Fields that say when a market resolves at the latest. DEADLINE_FIELDS = ("resolutionDeadline", "decisionDeadline", "listingDeadline", "time", "expiry", "dateTime") -"""Fields that say when a market resolves at the latest.""" # --------------------------------------------------------------------------- diff --git a/synpath/ids.py b/synpath/ids.py index 44e2d3a..bfeea06 100644 --- a/synpath/ids.py +++ b/synpath/ids.py @@ -16,10 +16,10 @@ """ from __future__ import annotations +# Every venue this library speaks, by id. `synpath.exchanges` is the same +# list with the adapter classes attached; this one exists so the id helpers do +# not import the adapters. VENUES: tuple[str, ...] = ("kalshi", "polymarket", "polymarket_us", "opinion", "hyperliquid", "predict_fun", "limitless", "sx_bet", "betfair", "smarkets") -"""Every venue this library speaks, by id. `synpath.exchanges` is the same -list with the adapter classes attached; this one exists so the id helpers do -not import the adapters.""" def qualify(venue: str, native_id: str) -> str: diff --git a/synpath/kalshi.py b/synpath/kalshi.py index 71e41cd..664ed3e 100644 --- a/synpath/kalshi.py +++ b/synpath/kalshi.py @@ -39,66 +39,65 @@ BASE_URL = "https://external-api.kalshi.com/trade-api/v2" +# Where Kalshi's text search lives. +# +# Not part of the documented Trade API, which has no search at all: its `/events` +# endpoint accepts a `query` parameter and silently ignores it, returning the +# unfiltered first page. This is the endpoint kalshi.com's own search box calls, +# on a different host -- `external-api.kalshi.com` answers 404 for it. +# +# Being undocumented, it can change or start requiring credentials without +# notice. When it fails, `search_markets` raises rather than falling back to +# scanning the catalog: a scan for a term that matches nothing walks ~13,000 +# events, and turning a fast failure into a hundred-second one is not a +# kindness. SEARCH_URL = "https://api.elections.kalshi.com" -"""Where Kalshi's text search lives. - -Not part of the documented Trade API, which has no search at all: its `/events` -endpoint accepts a `query` parameter and silently ignores it, returning the -unfiltered first page. This is the endpoint kalshi.com's own search box calls, -on a different host -- `external-api.kalshi.com` answers 404 for it. - -Being undocumented, it can change or start requiring credentials without -notice. When it fails, `search_markets` raises rather than falling back to -scanning the catalog: a scan for a term that matches nothing walks ~13,000 -events, and turning a fast failure into a hundred-second one is not a -kindness. -""" +# Deliberately slower than the trade API's limiter. This host publishes no +# limits and is not part of the documented surface, so it gets the conservative +# end of the guess. SEARCH_LIMITER = RateLimiter(2.0, burst=2) -"""Deliberately slower than the trade API's limiter. This host publishes no -limits and is not part of the documented surface, so it gets the conservative -end of the guess.""" VENUE = "kalshi" +# Kalshi rate-limits its public reads without documenting it in a header. +# Measured: an unpaced burst starts returning 429 around 8 req/s, 5.2 req/s ran +# clean, and a 429 clears in about a second. Module-level, so every client in the +# process shares one budget — which is what the venue actually counts. LIMITER = RateLimiter(5.0, burst=5) -"""Kalshi rate-limits its public reads without documenting it in a header. -Measured: an unpaced burst starts returning 429 around 8 req/s, 5.2 req/s ran -clean, and a 429 clears in about a second. Module-level, so every client in the -process shares one budget — which is what the venue actually counts.""" +# Tickers per `/markets` call. The ceiling is URI length, not a count (500 is +# fine, 800 returns 414), so this sits well under it rather than relying on a +# limit that depends on how long tickers happen to be. MARKET_BATCH = 200 -"""Tickers per `/markets` call. The ceiling is URI length, not a count (500 is -fine, 800 returns 414), so this sits well under it rather than relying on a -limit that depends on how long tickers happen to be.""" _SETTLED = {"settled", "determined", "finalized"} _OPEN = {"active", "open"} _UNOPENED = {"unopened", "initialized"} +# Results per search page. The venue's own page size; asking for more is +# ignored. SEARCH_PAGE = 50 -"""Results per search page. The venue's own page size; asking for more is -ignored.""" +# Event tickers per `/events?tickers=` call. A full page of search results is +# at most `MAX_PAGE_LIMIT` rows, so one batch covers it; 100 tickers measured at +# under 1,900 characters of URL. EVENT_BATCH = 100 -"""Event tickers per `/events?tickers=` call. A full page of search results is -at most `MAX_PAGE_LIMIT` rows, so one batch covers it; 100 tickers measured at -under 1,900 characters of URL.""" +# The most events Kalshi returns per `/events` call. Used when walking the +# whole catalog, where there is no page contract to honour and every extra +# request is spent rate budget. VENUE_EVENT_PAGE = 200 -"""The most events Kalshi returns per `/events` call. Used when walking the -whole catalog, where there is no page contract to honour and every extra -request is spent rate budget.""" EVENT_PAGE = 25 +# Most event pages one `fetch_markets` call will read while filling a page. +# +# A status filter can leave an event page with few matching markets, and filling +# `limit` from sparse pages must not turn into walking the catalog inside one +# call. Past this, the call returns what it has with a cursor to continue, which +# is what a short page with a cursor means. MAX_FILL_PAGES = 10 -"""Most event pages one `fetch_markets` call will read while filling a page. - -A status filter can leave an event page with few matching markets, and filling -`limit` from sparse pages must not turn into walking the catalog inside one -call. Past this, the call returns what it has with a cursor to continue, which -is what a short page with a cursor means.""" """Events fetched per underlying request while filling a market page. Kalshi averages about nine markets per event, so 25 events covers a full @@ -106,37 +105,37 @@ that overflows is resumed mid-way rather than re-walked. """ +# How many markets' face values to remember. Bounded because the server keeps +# one adapter for the life of the process. FACE_VALUE_CACHE = 2048 -"""How many markets' face values to remember. Bounded because the server keeps -one adapter for the life of the process.""" +# The only period_interval values Kalshi accepts, in minutes. CANDLE_INTERVALS = {"1m": 1, "1h": 60, "1d": 1440} -"""The only period_interval values Kalshi accepts, in minutes.""" +# Most bars Kalshi answers per candlestick request; a wider window is refused +# with `max candlesticks: 5000` (about 3.5 days of 1m, 208 days of 1h). Longer +# windows are read in pieces of `MAX_CANDLES - 1` periods and joined. MAX_CANDLES = 5000 -"""Most bars Kalshi answers per candlestick request; a wider window is refused -with `max candlesticks: 5000` (about 3.5 days of 1m, 208 days of 1h). Longer -windows are read in pieces of `MAX_CANDLES - 1` periods and joined.""" +# Prefix of a `fetch_trades` cursor that has moved past the live tape into +# `/historical/trades`. Kalshi keeps recent data on its live endpoints and moves +# anything older than a cutoff (see `/historical/cutoff`) to `/historical/*`: +# markets settled before it, and trades created before it, are no longer on the +# live endpoints at all. A plain cursor is the live tape's own. HISTORICAL_CURSOR = "historical:" -"""Prefix of a `fetch_trades` cursor that has moved past the live tape into -`/historical/trades`. Kalshi keeps recent data on its live endpoints and moves -anything older than a cutoff (see `/historical/cutoff`) to `/historical/*`: -markets settled before it, and trades created before it, are no longer on the -live endpoints at all. A plain cursor is the live tape's own.""" +# Statuses whose listings continue past the live catalog into +# `/historical/markets`, where Kalshi keeps markets settled before its cutoff. +# The live `/events` endpoint still lists those events, but with no markets +# inside them. ARCHIVE_STATUSES = {"settled", "all"} -"""Statuses whose listings continue past the live catalog into -`/historical/markets`, where Kalshi keeps markets settled before its cutoff. -The live `/events` endpoint still lists those events, but with no markets -inside them.""" +# Most markets `/historical/markets` returns per call; 1001 is a 400. ARCHIVE_PAGE = 1000 -"""Most markets `/historical/markets` returns per call; 1001 is a 400.""" +# Seconds to remember the historical cutoff. Kalshi moves it forward in steps +# of weeks, so asking once an hour is plenty. CUTOFF_TTL = 3600.0 -"""Seconds to remember the historical cutoff. Kalshi moves it forward in steps -of weeks, so asking once an hour is plenty.""" # --------------------------------------------------------------------------- @@ -1023,6 +1022,15 @@ def _events_by_ticker( event["markets"] = [*(event["markets"] or []), raw] return {ticker: normalize_event(raw) for ticker, raw in raw_events.items()} + def fetch_events_by_ids(self, event_ids: list[str]) -> list[Event]: + """Many events with their markets nested, in the order asked for + (`GET /events?tickers=`, batched). Ids the venue does not serve are + left out, as `fetch_markets_by_ids` leaves out closed markets. The + combo board reads a collection's events this way.""" + tickers = [ids.native(VENUE, event_id) if ":" in event_id else event_id for event_id in event_ids] + found = self._raw_events_by_ticker(tickers) + return [normalize_event(found[ticker]) for ticker in dict.fromkeys(tickers) if ticker in found] + def _raw_events_by_ticker(self, tickers: list[str]) -> dict[str, dict[str, Any]]: """The venue's event records with nested markets, keyed by ticker.""" unique = list(dict.fromkeys(tickers)) diff --git a/synpath/limitless.py b/synpath/limitless.py index 20105de..565dd21 100644 --- a/synpath/limitless.py +++ b/synpath/limitless.py @@ -47,32 +47,32 @@ VENUE = "limitless" WEB_URL = "https://limitless.exchange/markets/" +# The venue publishes no limit for its public endpoints; kept modest, shared +# process-wide. LIMITER = RateLimiter(5.0, burst=10) -"""The venue publishes no limit for its public endpoints; kept modest, shared -process-wide.""" +# Rows per listing page: the venue's maximum. PAGE = 25 -"""Rows per listing page: the venue's maximum.""" +# Most listing pages one walk reads: 2,500 rows, against about 560 today. MAX_PAGES = 100 -"""Most listing pages one walk reads: 2,500 rows, against about 560 today.""" +# Sizes, volumes and amounts are 6-decimal integers (USDC, and shares). SCALE = 1_000_000 -"""Sizes, volumes and amounts are 6-decimal integers (USDC, and shares).""" +# Prices are quoted to a tenth of a cent. TICK = 0.001 -"""Prices are quoted to a tenth of a cent.""" +# The venue's lifecycle as this library's status. A market trades only while +# `FUNDED` and not yet expired; `CREATED` and `DRAFTED` have not opened. STATUSES = {"FUNDED": "open", "LOCKED": "closed", "RESOLVED": "settled", "CREATED": "closed", "DRAFTED": "closed"} -"""The venue's lifecycle as this library's status. A market trades only while -`FUNDED` and not yet expired; `CREATED` and `DRAFTED` have not opened.""" +# This library's sort keys the venue can order the listing by. It has no +# volume or liquidity order. SORT_BY = {"newest": "newest"} -"""This library's sort keys the venue can order the listing by. It has no -volume or liquidity order.""" +# The price-history lookback presets, shortest first; `all` covers the rest. LOOKBACKS = (("5m", 300), ("1h", 3600), ("6h", 6 * 3600), ("1d", 86400), ("1w", 7 * 86400), ("1m", 30 * 86400)) -"""The price-history lookback presets, shortest first; `all` covers the rest.""" DEFAULT_BARS = 96 diff --git a/synpath/matching.py b/synpath/matching.py index 49853be..bf81430 100644 --- a/synpath/matching.py +++ b/synpath/matching.py @@ -33,9 +33,9 @@ from .types import EventMatch, MarketLink, MarketMatch BASE_URL_ENV = "SYNPATH_MATCHING_URL" +# Where the hosted matching service lives: Synpath's own, unless `base_url=` or +# `SYNPATH_MATCHING_URL` points somewhere else (a staging deployment, a local one). DEFAULT_URL = "https://api.synpath.dev" -"""Where the hosted matching service lives: Synpath's own, unless `base_url=` or -`SYNPATH_MATCHING_URL` points somewhere else (a staging deployment, a local one).""" def _client(base_url: str | None, http_client: Any = None) -> HttpClient: diff --git a/synpath/opinion.py b/synpath/opinion.py index c3524e6..6e52203 100644 --- a/synpath/opinion.py +++ b/synpath/opinion.py @@ -44,56 +44,56 @@ ) OPENAPI_URL = "https://openapi.opinion.trade/openapi" +# A public BNB Chain node, for the one contract read this adapter makes. RPC_URL = "https://bsc-dataseed.binance.org" -"""A public BNB Chain node, for the one contract read this adapter makes.""" VENUE = "opinion" +# The venue's published public limit: 5 requests a second per IP (15 per +# API key). Shared process-wide, as every venue's is. LIMITER = RateLimiter(5.0, burst=5) -"""The venue's published public limit: 5 requests a second per IP (15 per -API key). Shared process-wide, as every venue's is.""" +# Topics per catalog call: the venue's maximum. PAGE = 20 -"""Topics per catalog call: the venue's maximum.""" +# Most catalog pages one `fetch_markets` or `fetch_events` call reads while +# filling `limit`, at 5 requests a second about two seconds. Past it the call +# returns what it has, with a cursor. MAX_PAGES = 10 -"""Most catalog pages one `fetch_markets` or `fetch_events` call reads while -filling `limit`, at 5 requests a second about two seconds. Past it the call -returns what it has, with a cursor.""" +# Most catalog pages one search call scans: 500 topics, more than the whole +# open catalog (about 300 topics) and two seconds at the public rate limit. +# The venue has no search endpoint, so a query is matched here. MAX_SEARCH_PAGES = 25 -"""Most catalog pages one search call scans: 500 topics, more than the whole -open catalog (about 300 topics) and two seconds at the public rate limit. -The venue has no search endpoint, so a query is matched here.""" +# Most `price-history` calls one `fetch_ohlcv` makes walking back to `since`. +# A call returns at most 1000 samples, so this is 10,000 hours (over a year) of +# hourly samples. MAX_HISTORY_PAGES = 10 -"""Most `price-history` calls one `fetch_ohlcv` makes walking back to `since`. -A call returns at most 1000 samples, so this is 10,000 hours (over a year) of -hourly samples.""" +# Bars looked back when neither `since` nor `limit` bounds the window. DEFAULT_BARS = 100 -"""Bars looked back when neither `since` nor `limit` bounds the window.""" +# Opinion's FeeManager contract on BNB Chain, from the venue's own SDK. FEE_MANAGER = "0xC9063Dc52dEEfb518E5b6634A6b8D624bc5d7c36" -"""Opinion's FeeManager contract on BNB Chain, from the venue's own SDK.""" +# Selector of `getFeeRateSettings(uint256 tokenId)`, which returns +# `(makerFeeRateBps, takerFeeRateBps, enabled, minFeeAmount)`. GET_FEE_RATE_SETTINGS = "0x27f68850" -"""Selector of `getFeeRateSettings(uint256 tokenId)`, which returns -`(makerFeeRateBps, takerFeeRateBps, enabled, minFeeAmount)`.""" +# The venue's documented minimum fee per order, in USDT. The contract's own +# `minFeeAmount` reads 0; the fee docs state this floor is applied when the +# curve fee comes out smaller, so the documented figure is the one used. MIN_FEE = 0.25 -"""The venue's documented minimum fee per order, in USDT. The contract's own -`minFeeAmount` reads 0; the fee docs state this floor is applied when the -curve fee comes out smaller, so the documented figure is the one used.""" +# The venue's numeric status, as (normalized status, native word). STATUSES = {1: ("unopened", "created"), 2: ("open", "activated"), 3: ("closed", "resolving"), 4: ("settled", "resolved")} -"""The venue's numeric status, as (normalized status, native word).""" +# This library's sort keys as the venue's `sortBy` codes: 5 is 24h volume, +# descending; 1 is newest first. The venue has no liquidity ordering. SORT_BY = {"volume": 5, "newest": 1} -"""This library's sort keys as the venue's `sortBy` codes: 5 is 24h volume, -descending; 1 is newest first. The venue has no liquidity ordering.""" +# The venue's error number for an id it does not know ("Topic ID does not exist"). NOT_FOUND = 10200 -"""The venue's error number for an id it does not know ("Topic ID does not exist").""" # --------------------------------------------------------------------------- @@ -464,8 +464,8 @@ def __init__( client = httpx.Client(timeout=timeout, follow_redirects=True, headers={"apikey": api_key}) self.http = HttpClient(openapi_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) self.rpc = HttpClient(rpc_url, limiter=None, timeout=timeout, venue=VENUE) + # Per market: `(yes_token, no_token)`, filled by every catalog read. self._tokens: dict[str, tuple[str, str]] = {} - """Per market: `(yes_token, no_token)`, filled by every catalog read.""" # -- catalog ------------------------------------------------------------ diff --git a/synpath/polymarket.py b/synpath/polymarket.py index 95e776f..9cee0ac 100644 --- a/synpath/polymarket.py +++ b/synpath/polymarket.py @@ -40,49 +40,51 @@ DATA_URL = "https://data-api.polymarket.com" VENUE = "polymarket" +# Gamma took 30 requests at 43/s without complaint, so this is headroom rather +# than a measured ceiling: present so an undocumented limit throttles us instead +# of failing us. Shared process-wide, as Kalshi's is. LIMITER = RateLimiter(20.0, burst=20) -"""Gamma took 30 requests at 43/s without complaint, so this is headroom rather -than a measured ceiling: present so an undocumented limit throttles us instead -of failing us. Shared process-wide, as Kalshi's is.""" +# Tokens per POST /books. 500 answered in 0.32s; 672 was "Payload exceeds the limit". BOOK_BATCH = 500 -"""Tokens per POST /books. 500 answered in 0.32s; 672 was "Payload exceeds the limit".""" +# Gamma enforces this: 200 ids returns `422 expected array length <= 100`. MARKET_BATCH = 100 -"""Gamma enforces this: 200 ids returns `422 expected array length <= 100`.""" +# Gamma events per `GET /events?id=` read: each carries every market of its event. +EVENT_BATCH = 20 +# Rows per Data API `/trades` call, its maximum. TRADE_PAGE = 500 -"""Rows per Data API `/trades` call, its maximum.""" +# Most tape pages one `fetch_ohlcv` call reads: 10,000 trades. A hot market +# was measured printing 500 trades in 68 minutes, so this is a few hours of +# one and weeks of a quiet one. Past it the call stops and marks the earliest +# bar incomplete rather than walking the tape indefinitely. MAX_TRADE_PAGES = 20 -"""Most tape pages one `fetch_ohlcv` call reads: 10,000 trades. A hot market -was measured printing 500 trades in 68 minutes, so this is a few hours of -one and weeks of a quiet one. Past it the call stops and marks the earliest -bar incomplete rather than walking the tape indefinitely.""" +# Widest `startTs`..`endTs` span, in seconds, one `prices-history` request is +# sent. The venue refuses a wider one (`interval is too long`; 15 days measured +# fine, 16 refused), so longer windows are read in pieces and joined. QUOTE_WINDOW = 14 * 86400 -"""Widest `startTs`..`endTs` span, in seconds, one `prices-history` request is -sent. The venue refuses a wider one (`interval is too long`; 15 days measured -fine, 16 refused), so longer windows are read in pieces and joined.""" +# Bars looked back when neither `since` nor `limit` bounds the window. DEFAULT_BARS = 100 -"""Bars looked back when neither `since` nor `limit` bounds the window.""" +# Events per `/public-search` page: its maximum; a larger `limit_per_type` +# is answered with 50. SEARCH_PAGE = 50 -"""Events per `/public-search` page: its maximum; a larger `limit_per_type` -is answered with 50.""" +# Most search pages one `fetch_markets(query=...)` call reads while filling +# `limit` markets. Past it the call returns what it has, with a cursor. MAX_SEARCH_PAGES = 5 -"""Most search pages one `fetch_markets(query=...)` call reads while filling -`limit` markets. Past it the call returns what it has, with a cursor.""" +# The deepest offset the Data API's `/trades` accepts; past it the venue +# answers `max historical trades offset of 10000 exceeded`. MAX_TRADE_OFFSET = 10_000 -"""The deepest offset the Data API's `/trades` accepts; past it the venue -answers `max historical trades offset of 10000 exceeded`.""" +# This library's sort keys as Gamma's own field names. SORT_FIELDS = {"volume": "volume24hr", "liquidity": "liquidityNum", "newest": "startDate"} -"""This library's sort keys as Gamma's own field names.""" from .base import MAX_PAGE_LIMIT # noqa: E402 (kept next to the cap it mirrors) """Gamma's keyset endpoints cap a page at 100 rows whatever is asked for, which @@ -540,8 +542,8 @@ def __init__( self.gamma = HttpClient(gamma_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) self.clob = HttpClient(clob_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) self.data = HttpClient(data_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE) + # Per market: `(yes_token, no_token, condition_id)`, filled by every catalog read. self._tokens: dict[str, tuple[str, str, str]] = {} - """Per market: `(yes_token, no_token, condition_id)`, filled by every catalog read.""" # -- catalog ------------------------------------------------------------ @@ -668,6 +670,26 @@ def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]: found[str(raw["id"])] = self._remember(normalize_market(raw)) return [found[market_id] for market_id in natives if market_id in found] + def fetch_events_by_ids(self, event_ids: list[str]) -> list[Event]: + """Many events with their markets, in the order asked for (Gamma + `GET /events?id=`, batched; `info` keeps the event's own fields, such + as a game's `teams`). Ids Gamma does not serve are left out. The + combo board reads its games' teams this way.""" + natives = [self.native(event_id) for event_id in event_ids] + found: dict[str, Event] = {} + unique = list(dict.fromkeys(natives)) + for start in range(0, len(unique), EVENT_BATCH): + batch = unique[start:start + EVENT_BATCH] + payload = self.gamma.get("/events", [("id", event_id) for event_id in batch] + [("limit", len(batch))]) + for raw in payload if isinstance(payload, list) else []: + if raw.get("id") is None: + continue + try: + found[str(raw["id"])] = normalize_event(raw) + except BadRequest: + continue + return [found[event_id] for event_id in natives if event_id in found] + def fetch_market(self, market_id: str) -> Market: native = self.native(market_id) payload = self.gamma.get(f"/markets/{native}") diff --git a/synpath/polymarket_us.py b/synpath/polymarket_us.py index 8972de5..c4b6bd3 100644 --- a/synpath/polymarket_us.py +++ b/synpath/polymarket_us.py @@ -55,37 +55,37 @@ SITE_URL = "https://polymarket.us" VENUE = "polymarket_us" +# The venue documents 20 requests per second per IP for the public gateway, +# answering 429 with no Retry-After beyond that. Set below the ceiling because +# the limit is per IP and this budget is per process: two processes on one +# machine each at 20/s would be at 40/s. Shared process-wide, as the others are. LIMITER = RateLimiter(15.0, burst=15) -"""The venue documents 20 requests per second per IP for the public gateway, -answering 429 with no Retry-After beyond that. Set below the ceiling because -the limit is per IP and this budget is per process: two processes on one -machine each at 20/s would be at 40/s. Shared process-wide, as the others are.""" +# Slugs per `/markets?slug=` call. The ceiling is URI length; 50 slugs of the +# lengths seen here stay under 2,500 characters. MARKET_BATCH = 50 -"""Slugs per `/markets?slug=` call. The ceiling is URI length; 50 slugs of the -lengths seen here stay under 2,500 characters.""" +# The venue's published maker coefficient, a rebate. Fees are `theta * C * p +# * (1 - p)`; the taker theta is on each market as `feeCoefficient`, the maker +# theta is venue-wide and appears only in the fee schedule document. MAKER_THETA = -0.0125 -"""The venue's published maker coefficient, a rebate. Fees are `theta * C * p -* (1 - p)`; the taker theta is on each market as `feeCoefficient`, the maker -theta is venue-wide and appears only in the fee schedule document.""" +# Most search pages one `search_markets` call reads while filling `limit` +# markets. Past it the call returns what it has, with a cursor. MAX_SEARCH_PAGES = 5 -"""Most search pages one `search_markets` call reads while filling `limit` -markets. Past it the call returns what it has, with a cursor.""" +# Packs a search cursor's page and markets-to-skip into one number: +# `page * SEARCH_SKIP + skip`. SEARCH_SKIP = 1_000_000 -"""Packs a search cursor's page and markets-to-skip into one number: -`page * SEARCH_SKIP + skip`.""" +# Results per search page when the caller gives no limit. SEARCH_PAGE = 20 -"""Results per search page when the caller gives no limit.""" _SETTLED_STATUSES = {"MARKET_STATUS_RESOLVED", "MARKET_STATUS_SETTLED"} +# Book states in which the venue accepts orders. Everything else -- +# pre-open, suspended, halted, expired, terminated, auction -- is listed and not +# trading, which is `status="open"` with `active=False`. _OPEN_STATES = {"MARKET_STATE_OPEN"} -"""Book states in which the venue accepts orders. Everything else -- -pre-open, suspended, halted, expired, terminated, auction -- is listed and not -trading, which is `status="open"` with `active=False`.""" # --------------------------------------------------------------------------- diff --git a/synpath/predict_fun.py b/synpath/predict_fun.py index fa9760e..cb7bbc2 100644 --- a/synpath/predict_fun.py +++ b/synpath/predict_fun.py @@ -46,32 +46,32 @@ VENUE = "predict_fun" WEB_URL = "https://predict.fun/market/" +# The venue allows 240 requests a minute per API key; kept just under it, +# shared process-wide. LIMITER = RateLimiter(3.8, burst=8) -"""The venue allows 240 requests a minute per API key; kept just under it, -shared process-wide.""" +# Amounts and prices on trades are 18-decimal integers (USDT and shares on BNB Chain). WEI = 10**18 -"""Amounts and prices on trades are 18-decimal integers (USDT and shares on BNB Chain).""" +# The venue's market lifecycle, as this library's status. A proposed or +# disputed price is the outcome being decided: trading has stopped, nothing is +# paid yet. STATUSES = { "REGISTERED": "open", "UNPAUSED": "open", "PAUSED": "closed", "PRICE_PROPOSED": "closed", "PRICE_DISPUTED": "closed", "RESOLVED": "settled", "REMOVED": "closed", } -"""The venue's market lifecycle, as this library's status. A proposed or -disputed price is the outcome being decided: trading has stopped, nothing is -paid yet.""" +# This library's sort keys the venue can order markets by. It orders +# categories by publication, markets by volume only. SORT_BY = {"volume": "VOLUME_24H_DESC"} -"""This library's sort keys the venue can order markets by. It orders -categories by publication, markets by volume only.""" +# The venue's sample widths. A timeframe between them is built from the +# widest one that divides it. CANDLE_RESOLUTIONS = {60: "1m", 300: "5m", 3600: "1h", 86400: "1d"} -"""The venue's sample widths. A timeframe between them is built from the -widest one that divides it.""" DEFAULT_BARS = 100 +# Most venue pages one catalog call reads while filling `limit` after filtering. MAX_PAGES = 10 -"""Most venue pages one catalog call reads while filling `limit` after filtering.""" def api_key_from(api_key: str | None) -> str | None: diff --git a/synpath/remote.py b/synpath/remote.py index 08e5883..10f03b9 100644 --- a/synpath/remote.py +++ b/synpath/remote.py @@ -17,7 +17,9 @@ from ._deprecated import renamed_kwargs from .errors import AuthenticationError, BadRequest, ExchangeError, NetworkError, NotSupported from .server.local import resolve_key -from .trading.errors import InsufficientFunds, OrderNotFound, RiskRejected +from .trading.errors import ( + CancelUnconfirmed, InsufficientFunds, OrderNotFound, RiskRejected, SubmissionInDoubt, SubmissionPending, +) from .bucket import BUCKET_PREFIX, Bucket, BucketMember, BucketOrderReport, BucketPosition from .trading.types import Balance, EditRequest, FeeEstimate, Fill, Order, OrderRequest, Position, Settlement, Side from .types import Page @@ -57,10 +59,22 @@ async def _call(self, method: str, path: str, *, params: dict[str, Any] | None = response = await self._http.request(method, self.base + path, params=clean, json=json, headers=self._headers()) except httpx.HTTPError as exc: raise NetworkError(f"{method} {path}: {exc}") from exc - if response.status_code >= 400: + if response.status_code >= 400 or self._is_unresolved(response): raise self._error(response, method, path) return response.json() if response.content else None + @staticmethod + def _is_unresolved(response: httpx.Response) -> bool: + """A 202 carrying the error envelope: the order's outcome is not known + yet (`submission_in_doubt` / `submission_pending`). Not a result.""" + if response.status_code != 202 or not response.content: + return False + try: + body = response.json() + except ValueError: + return False + return isinstance(body, dict) and isinstance(body.get("error"), dict) + @staticmethod def _error(response: httpx.Response, method: str, path: str) -> Exception: """The server's `{"error": {"code", "message", "details"}}` as the @@ -73,6 +87,13 @@ def _error(response: httpx.Response, method: str, path: str) -> Exception: message = str(error.get("message") or response.text or response.reason_phrase) details = error.get("details") or {} status = response.status_code + client_order_id = details.get("client_order_id") + if code == "submission_in_doubt": + return SubmissionInDoubt(message, client_order_id=client_order_id) + if code == "submission_pending": + return SubmissionPending(message, client_order_id=client_order_id) + if code == "cancel_unconfirmed": + return CancelUnconfirmed(message) if status in (401, 403): return AuthenticationError(message) if status == 404: @@ -145,9 +166,23 @@ async def fetch_order(self, order_id: str, *, market_id: str | None = None, venu return Order.model_validate(await self._call("GET", f"/orders/{order_id}")) async def fetch_open_orders(self, *, market_id: str | None = None, venue: str | None = None) -> list[Order]: - page = await self._call("GET", "/orders", params={"venue": venue}) - orders = [Order.model_validate(row) for row in page.get("data", [])] - return [o for o in orders if market_id is None or o.market_id == market_id] + orders: list[Order] = [] + cursor: str | None = None + while True: + page = await self.fetch_orders(scope="open", market_id=market_id, venue=venue, cursor=cursor) + orders.extend(page) + cursor = page.next_cursor + if not cursor: + return orders + + async def fetch_orders(self, *, scope: str = "all", market_id: str | None = None, venue: str | None = None, + since: int | None = None, limit: int | None = None, + cursor: str | None = None) -> Page[Order]: + """One page of the server's orders, newest first: `all` states (the + default) or only `open` ones, engine-held parents included.""" + page = await self._call("GET", "/orders", params={ + "scope": scope, "market_id": market_id, "venue": venue, "since": since, "limit": limit, "cursor": cursor}) + return Page([Order.model_validate(row) for row in page.get("data", [])], next_cursor=page.get("next_cursor")) # -- portfolio ------------------------------------------------------------ diff --git a/synpath/server/__init__.py b/synpath/server/__init__.py index f72d31d..eadbc4a 100644 --- a/synpath/server/__init__.py +++ b/synpath/server/__init__.py @@ -27,7 +27,8 @@ The read app has no auth of its own and never will — a service that ships authentication inside the open-source core forces every host to work around -it. Everything cross-venue (matching the same question across exchanges, +it. A host plugs its own in with `create_app(dependencies=[...])`, which is +how `synpath serve` requires its access token on every market-data route. Everything cross-venue (matching the same question across exchanges, routing an order) belongs outside too. **Trading is a second app**, because placing an order does need to know who diff --git a/synpath/server/__main__.py b/synpath/server/__main__.py index f1d3339..bb8c750 100644 --- a/synpath/server/__main__.py +++ b/synpath/server/__main__.py @@ -66,8 +66,9 @@ async def run() -> None: def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser( prog="python -m synpath.server", - description="Serve the synpath API. The read app has no authentication: bind to localhost, " - "or mount it behind your own middleware for anything public.", + description="Serve the synpath read API alone. It has no authentication: bind to localhost, " + "or mount it behind your own middleware for anything public. `synpath serve` runs the " + "whole stack, with every route behind its access token.", ) parser.add_argument("--host", default="127.0.0.1", help="default: 127.0.0.1") parser.add_argument("--port", type=int, default=8000, help="default: 8000") diff --git a/synpath/server/api.py b/synpath/server/api.py index d44f40b..e37a5bb 100644 --- a/synpath/server/api.py +++ b/synpath/server/api.py @@ -1,4 +1,6 @@ -"""The HTTP surface over the library. No authentication, on purpose. +"""The HTTP surface over the library. No authentication of its own, on purpose: +a host passes its own as `create_app(dependencies=...)` (as `synpath serve` +does with its access token) or wraps the router. This app is the *inner* app. It knows how to turn a request into a library call and a library exception into a status code, and nothing else — no API @@ -29,10 +31,11 @@ """ from __future__ import annotations +import inspect import logging import threading from contextlib import asynccontextmanager -from typing import Literal, Annotated, Any, Iterator +from typing import Literal, Annotated, Any, Iterator, Sequence from fastapi import Depends, FastAPI, HTTPException, Path, Query, Request from fastapi.responses import JSONResponse @@ -64,6 +67,12 @@ log = logging.getLogger("synpath.server") +# Library exception to status code. +# +# The two that are worth explaining: `NotSupported` is 501 rather than 404, +# because the venue has no such capability at all and no id would have worked; +# and a venue failing us is 502, not 500, because this service is fine and is +# reporting on an upstream that is not. STATUS_FOR: dict[type[Exception], int] = { MarketNotFound: 404, BadRequest: 400, @@ -74,13 +83,14 @@ NetworkError: 502, ExchangeError: 502, } -"""Library exception to status code. -The two that are worth explaining: `NotSupported` is 501 rather than 404, -because the venue has no such capability at all and no id would have worked; -and a venue failing us is 502, not 500, because this service is fine and is -reporting on an upstream that is not. -""" + +def takes_keyword(function: Any, name: str) -> bool: + """Whether `function` accepts `name=` (named, or through `**kwargs`): how a + route tells a venue-specific option (Polymarket's candle `source`) from one + the venue would reject with a TypeError.""" + parameters = inspect.signature(function).parameters.values() + return any(p.name == name or p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters) def _status_for(exc: Exception) -> int: @@ -145,9 +155,9 @@ def close(self) -> None: self._exchanges.clear() +# Used when a request arrives on an app that carries no registry of its own — +# which is what happens when the router is mounted on somebody else's app. DEFAULT_REGISTRY = VenueRegistry() -"""Used when a request arrives on an app that carries no registry of its own — -which is what happens when the router is mounted on somebody else's app.""" def get_venue( @@ -171,11 +181,15 @@ def get_venue( return registry.get(venue) +# Module level on purpose: `from __future__ import annotations` turns every +# annotation into a string, and FastAPI resolves those against module globals. +# Defined inside the factory it would be invisible and the app would fail to +# build its schema. Venue = Annotated[Exchange, Depends(get_venue)] -"""Module level on purpose: `from __future__ import annotations` turns every -annotation into a string, and FastAPI resolves those against module globals. -Defined inside the factory it would be invisible and the app would fail to -build its schema.""" + +# Bars one candle read may ask for: two weeks of minutes. A chart reaching back +# over a quiet market asks for many periods to find enough bars that traded. +MAX_CANDLES = 20_000 RESPONSES: dict[int | str, dict[str, Any]] = { 400: {"model": ErrorBody, "description": "Invalid parameters."}, @@ -192,8 +206,14 @@ def create_app( registry: VenueRegistry | None = None, title: str = "synpath", docs: bool = True, + dependencies: Sequence[Any] | None = None, ) -> FastAPI: - """Build the app. Pass `registry` to inject stub adapters in tests.""" + """Build the app. Pass `registry` to inject stub adapters in tests. + + `dependencies` run before every route except `/` and `/health`, which + say what the server is and whether it is up and touch no venue. The app + has no authentication of its own; this is where a host that owns one + (such as `synpath serve`, which requires its access token) plugs it in.""" registry = registry or DEFAULT_REGISTRY @asynccontextmanager @@ -240,6 +260,8 @@ def index(request: Request) -> dict[str, Any]: trading = getattr(request.app.state, "trading_path", None) if trading: links["trading"] = trading + if getattr(request.app.state, "market_streams", None) is not None: + links["market_stream"] = "/ws/market" return {"name": "synpath", "version": synpath.__version__, "links": links} @app.get("/health", tags=["meta"], summary="Liveness") @@ -247,7 +269,10 @@ def health() -> dict[str, str]: """Answers without touching a venue, so it stays useful when one is down.""" return {"status": "ok", "version": synpath.__version__} - @app.get("/venues", tags=["meta"], summary="List venues and capabilities") + # Everything below reads venue data, so it carries the host's dependencies. + protected = list(dependencies or ()) + + @app.get("/venues", dependencies=protected, tags=["meta"], summary="List venues and capabilities") def list_venues() -> list[VenueInfo]: """Read this before anything else. `has` tells you which calls a venue answers and which it will refuse with a 501.""" @@ -258,7 +283,7 @@ def list_venues() -> list[VenueInfo]: for cls in synpath.exchanges.values() ] - @app.get("/venues/{venue}", tags=["meta"], summary="One venue", responses=RESPONSES) + @app.get("/venues/{venue}", dependencies=protected, tags=["meta"], summary="One venue", responses=RESPONSES) def get_venue_info(exchange: Venue) -> VenueInfo: return VenueInfo( id=exchange.id, name=exchange.name, @@ -268,7 +293,7 @@ def get_venue_info(exchange: Venue) -> VenueInfo: # -- catalog ------------------------------------------------------------ @app.get( - "/venues/{venue}/markets", tags=["catalog"], + "/venues/{venue}/markets", dependencies=protected, tags=["catalog"], summary="List markets", responses=RESPONSES, ) def list_markets( @@ -298,7 +323,7 @@ def list_markets( ) @app.get( - "/venues/{venue}/markets/{market_id}", tags=["catalog"], + "/venues/{venue}/markets/{market_id}", dependencies=protected, tags=["catalog"], summary="One market", responses=RESPONSES, ) def get_market( @@ -312,7 +337,7 @@ def get_market( return exchange.fetch_market(market_id) @app.get( - "/venues/{venue}/events", tags=["catalog"], + "/venues/{venue}/events", dependencies=protected, tags=["catalog"], summary="List events with markets nested", responses=RESPONSES, ) def list_events( @@ -332,7 +357,7 @@ def list_events( # -- market data -------------------------------------------------------- @app.get( - "/venues/{venue}/markets/{market_id}/book", tags=["market data"], + "/venues/{venue}/markets/{market_id}/book", dependencies=protected, tags=["market data"], summary="Order book for one side of a market", responses=RESPONSES, ) def get_order_book( @@ -356,14 +381,14 @@ def get_order_book( return exchange.fetch_order_book(market_id, side=side, depth=depth) @app.get( - "/venues/{venue}/markets/{market_id}/trades", tags=["market data"], + "/venues/{venue}/markets/{market_id}/trades", dependencies=protected, tags=["market data"], summary="Recent trades", responses=RESPONSES, ) def list_trades( exchange: Venue, market_id: str, since: Annotated[int | None, Query(description="Milliseconds since epoch, inclusive.")] = None, - limit: Annotated[int, Query(ge=1, le=100)] = 100, + limit: Annotated[int, Query(ge=1, le=1000)] = 100, cursor: Annotated[str | None, Query()] = None, ) -> PageResponse[Trade]: """Oldest first, in the YES price. `side` is what the taker did on the @@ -374,7 +399,7 @@ def list_trades( ) @app.get( - "/venues/{venue}/markets/{market_id}/candles", tags=["market data"], + "/venues/{venue}/markets/{market_id}/candles", dependencies=protected, tags=["market data"], summary="OHLCV candles, in the YES price", responses=RESPONSES, ) def list_candles( @@ -389,10 +414,15 @@ def list_candles( "first `limit` returned; without it, the newest `limit` bars." ))] = None, until: Annotated[int | None, Query(description="Milliseconds since epoch.")] = None, - limit: Annotated[int, Query(ge=1, le=1000)] = 100, + limit: Annotated[int, Query(ge=1, le=MAX_CANDLES)] = 100, cursor: Annotated[str | None, Query(description=( "`next_cursor` from the previous page of a read with `since`; continues after its last bar." ))] = None, + source: Annotated[str | None, Query(description=( + "Polymarket only: `trades` (the default, bars built from executions) or `quotes` (the venue's " + "sampled midpoint history, `price_source: sampled_mid`, no volume, reaching further back). " + "Other venues refuse it with 400." + ))] = None, ) -> PageResponse[Candle]: """Read `price_source` on every bar before using it. @@ -410,8 +440,14 @@ def list_candles( raise http_error(400, "validation_error", "cursor: not a cursor from this endpoint", field="cursor") since = int(cursor) seconds = timeframe_seconds(timeframe) + options = {} + if source is not None: + if not takes_keyword(exchange.fetch_ohlcv, "source"): + raise http_error(400, "validation_error", f"source: {exchange.id} has one candle source", + field="source") + options["source"] = source candles = exchange.fetch_ohlcv( - market_id, timeframe=timeframe, since=since, until=until, limit=limit, + market_id, timeframe=timeframe, since=since, until=until, limit=limit, **options, ) more = since is not None and len(candles) == limit next_cursor = str(candles[-1].timestamp + seconds * 1000) if more else None @@ -420,7 +456,7 @@ def list_candles( # -- reference ---------------------------------------------------------- @app.get( - "/venues/{venue}/markets/{market_id}/fee", tags=["reference"], + "/venues/{venue}/markets/{market_id}/fee", dependencies=protected, tags=["reference"], summary="Fee schedule, before trading", responses=RESPONSES, ) def get_fee_schedule(exchange: Venue, market_id: str) -> FeeSchedule: @@ -429,13 +465,19 @@ def get_fee_schedule(exchange: Venue, market_id: str) -> FeeSchedule: return exchange.fetch_fee_schedule(market_id) @app.get( - "/venues/{venue}/series/{series_id}", tags=["reference"], + "/venues/{venue}/series/{series_id}", dependencies=protected, tags=["reference"], summary="One series", responses=RESPONSES, ) def get_series(exchange: Venue, series_id: str) -> Series: """Kalshi only. Polymarket has no series tier and answers 501.""" return exchange.fetch_series(series_id) + # -- batch reads and search, across venues ------------------------------- + + from .batch import add_batch_routes + + add_batch_routes(app, protected, RESPONSES) + return app diff --git a/synpath/server/batch.py b/synpath/server/batch.py new file mode 100644 index 0000000..12df625 --- /dev/null +++ b/synpath/server/batch.py @@ -0,0 +1,259 @@ +"""Batch reads and search across venues: many markets or books in one call, and +one query against several venues' catalogs. + +These are the read app's cross-venue *fan-out* routes, the HTTP form of +`synpath.Client.fetch_markets_by_ids`, `Client.fetch_order_books` and +`Client.fetch_markets` / `fetch_events` without a venue. Nothing here +compares or matches listings; each venue answers for its own ids, through +the same registry adapters as every other route, so the rate limiter and +connection pool stay shared. + +One rule for every route here: **a failure is reported where it happened, +never for the whole call.** Ids are grouped by venue and each venue is asked +once (Polymarket, Kalshi and the others batch natively); a venue that fails +fails only its own ids, a malformed id fails only itself, and an id the venue +no longer serves comes back as `market_not_found`. Every id asked for is in +exactly one of `data` and `errors`. Only a request that is wrong as a whole +(too many ids, a cursor with several venues) is refused outright. +""" +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +from typing import Annotated, Any, Callable, Generic, Literal, TypeVar + +from fastapi import FastAPI, Query, Request +from pydantic import BaseModel, Field + +import synpath +from synpath import Event, Market, OrderBook, SynpathError +from synpath import ids as synpath_ids +from synpath.errors import MarketNotFound, NetworkError, NotSupported + +from .errors import code_of, http_error +from .models import ErrorDetail, PageResponse + +# Ids one batch call may name. The terminal asks for at most 60 at a time; a +# caller with more splits them, so one request never holds a venue's whole rate +# budget. +MAX_BATCH_IDS = 100 + +MAX_SEARCH_LIMIT = 100 + +T = TypeVar("T") + + +class BatchMarkets(BaseModel): + """`GET /markets`: the markets found, in the order asked for, and why the rest are not there.""" + + data: list[Market] = Field(description="Markets found, in the order their ids were given (duplicates once).") + errors: dict[str, ErrorDetail] = Field( + default_factory=dict, + description="Synpath id -> why it is not in `data`: `bad_market_id`, `market_not_found` (the venue no " + "longer serves it), or the venue's own failure (`details.venue`, `details.retryable`).", + ) + + +class BatchBooks(BaseModel): + """`GET /books`: one side's book per market, keyed by Synpath id.""" + + data: dict[str, OrderBook] = Field(description="Synpath id -> its book, priced for `side`.") + errors: dict[str, ErrorDetail] = Field(default_factory=dict, description="Synpath id -> why it has no book.") + + +class VenueSearch(BaseModel, Generic[T]): + """`GET /search/...`: one page per venue, and the venues that failed.""" + + results: dict[str, PageResponse[T]] = Field( + description="Venue -> its page, most relevant first as the venue ranks it. `next_cursor` continues that " + "venue's search when exactly one venue was asked.") + errors: dict[str, ErrorDetail] = Field(default_factory=dict, description="Venue -> why it has no page.") + + +# --------------------------------------------------------------------------- +# Grouping ids and turning failures into per-id errors +# --------------------------------------------------------------------------- + +def parse_id_list(raw: str) -> list[str]: + """`"a, b,a"` -> `["a", "b"]`: trimmed, empty entries dropped, duplicates once, order kept.""" + wanted = list(dict.fromkeys(part.strip() for part in raw.split(",") if part.strip())) + if not wanted: + raise http_error(400, "validation_error", "ids: name at least one Synpath id", field="ids") + if len(wanted) > MAX_BATCH_IDS: + raise http_error(400, "too_many_ids", f"ids: at most {MAX_BATCH_IDS} per call; got {len(wanted)}", + field="ids", limit=MAX_BATCH_IDS) + return wanted + + +def group_by_venue(market_ids: list[str]) -> tuple[dict[str, list[str]], dict[str, ErrorDetail]]: + """Valid ids by venue, and an error for every id no venue can answer.""" + by_venue: dict[str, list[str]] = {} + errors: dict[str, ErrorDetail] = {} + for market_id in market_ids: + venue, native_id = synpath_ids.split(market_id) + if venue is None or not native_id: + errors[market_id] = detail("bad_market_id", f"{market_id!r} is not a Synpath id: expected " + f"'venue:native_id' with venue one of {', '.join(synpath_ids.VENUES)}") + elif venue not in synpath.exchanges: + errors[market_id] = detail("unknown_venue", f"unknown venue {venue!r}", venue=venue) + else: + by_venue.setdefault(venue, []).append(market_id) + return by_venue, errors + + +def detail(code: str, message: str, *, venue: str | None = None, retryable: bool = False) -> ErrorDetail: + return ErrorDetail(code=code, message=message, details={"venue": venue, "retryable": retryable}) + + +def error_of(exc: Exception, venue: str) -> ErrorDetail: + """A library exception as the same error a single-venue route would answer with.""" + if isinstance(exc, SynpathError): + return detail(code_of(exc), str(exc), venue=getattr(exc, "synpath_venue", None) or venue, + retryable=isinstance(exc, NetworkError)) + return detail("internal_error", f"{venue}: the request failed", venue=venue) + + +def per_venue(work: dict[str, Any], ask: Callable[[str, Any], Any]) -> dict[str, Any | Exception]: + """`ask(venue, item)` for every venue at once, each answer or the exception it raised. + + Venues share nothing, so they are asked in parallel: a search over eight + venues takes as long as the slowest, not the sum. Each venue's own rate + limiter still paces its requests.""" + if not work: + return {} + + def one(venue: str) -> Any: + try: + return ask(venue, work[venue]) + except Exception as exc: # reported per venue, never for the whole call + return exc + + with ThreadPoolExecutor(max_workers=min(len(work), 8), thread_name_prefix="synpath-batch") as pool: + return dict(zip(work, pool.map(one, work))) + + +def registry_of(request: Request) -> Any: + from .api import DEFAULT_REGISTRY + return getattr(request.app.state, "registry", None) or DEFAULT_REGISTRY + + +# --------------------------------------------------------------------------- +# The routes +# --------------------------------------------------------------------------- + +IdsQuery = Annotated[str, Query(max_length=20_000, description=( + f"Comma-separated Synpath ids (`kalshi:KXFOO-25,polymarket:2252244`), at most {MAX_BATCH_IDS}. " + "Duplicates are answered once."))] + +SearchQuery = Annotated[str, Query(min_length=1, max_length=200, description="The text to search for.")] +VenuesQuery = Annotated[list[str] | None, Query(alias="venue", description=( + "Repeat to name several venues; default every venue."))] +LimitQuery = Annotated[int, Query(ge=1, le=MAX_SEARCH_LIMIT, description="Per venue.")] +CursorQuery = Annotated[str | None, Query(description="`next_cursor` from a one-venue search.")] +StatusQuery = Annotated[str, Query(pattern="^(open|closed|settled|all)$")] + + + +def add_batch_routes(app: FastAPI, protected: list[Any], responses: dict[int | str, dict[str, Any]]) -> None: + """Add `GET /markets`, `GET /books`, `GET /search/markets` and `GET /search/events` to the read app.""" + + @app.get("/markets", dependencies=protected, tags=["batch"], summary="Many markets by id, across venues", + responses=responses) + def batch_markets(request: Request, ids: IdsQuery) -> BatchMarkets: + """`fetch_markets_by_ids`, one request per venue. This is the quote path: each market carries its + `yes.quote` and `no.quote` (bid, ask, last) and `stats`, as `fetch_markets_by_ids` returns them.""" + wanted = parse_id_list(ids) + by_venue, errors = group_by_venue(wanted) + registry = registry_of(request) + answers = per_venue(by_venue, lambda venue, batch: registry.get(venue).fetch_markets_by_ids(batch)) + found: dict[str, Market] = {} + for venue, answer in answers.items(): + if isinstance(answer, Exception): + errors.update({market_id: error_of(answer, venue) for market_id in by_venue[venue]}) + continue + found.update({market.id: market for market in answer}) + for market_id in by_venue[venue]: + if synpath_ids.qualify(venue, synpath_ids.split(market_id)[1]) not in found: + errors[market_id] = detail("market_not_found", f"{venue}: no market {market_id}", venue=venue) + data = [found[market_id] for market_id in wanted if market_id in found] + return BatchMarkets(data=data, errors=errors) + + @app.get("/books", dependencies=protected, tags=["batch"], summary="Many order books by id, across venues", + responses=responses) + def batch_books( + request: Request, + ids: IdsQuery, + side: Annotated[Literal["yes", "no"], Query(description="Which side every book is priced for.")] = "yes", + depth: Annotated[int | None, Query(ge=1, le=1000)] = None, + ) -> BatchBooks: + """`fetch_order_books`, one call per venue (Polymarket answers a batch in one request; venues + without a batch endpoint read one book per market against their shared rate budget).""" + wanted = parse_id_list(ids) + by_venue, errors = group_by_venue(wanted) + registry = registry_of(request) + + def books_of(venue: str, batch: list[str]) -> dict[str, OrderBook]: + exchange = registry.get(venue) + try: + return exchange.fetch_order_books(batch, side=side, depth=depth) + except NotSupported: + return {market_id: exchange.fetch_order_book(market_id, side=side, depth=depth) for market_id in batch} + + answers = per_venue(by_venue, books_of) + data: dict[str, OrderBook] = {} + for venue, answer in answers.items(): + if isinstance(answer, Exception): + errors.update({market_id: error_of(answer, venue) for market_id in by_venue[venue]}) + continue + for market_id in by_venue[venue]: + book = answer.get(synpath_ids.qualify(venue, synpath_ids.split(market_id)[1])) + if book is None: + errors[market_id] = detail("market_not_found", f"{venue}: no book for {market_id}", venue=venue) + else: + data[market_id] = book + return BatchBooks(data=data, errors=errors) + + def search(request: Request, kind: Literal["markets", "events"], query: str, venues: list[str] | None, + limit: int, cursor: str | None, status: str) -> dict[str, Any]: + """One query against each venue's own search (`fetch_markets(query=...)` / `fetch_events(query=...)`).""" + chosen = list(dict.fromkeys(venues or synpath.exchanges)) + unknown = [venue for venue in chosen if venue not in synpath.exchanges] + if unknown: + raise http_error(404, "unknown_venue", f"unknown venue {unknown[0]!r}; available: " + f"{', '.join(sorted(synpath.exchanges))}", field="venue") + if cursor is not None and len(chosen) != 1: + raise http_error(400, "validation_error", "cursor: a cursor belongs to one venue; name exactly one venue", + field="cursor") + registry = registry_of(request) + + def page_of(venue: str, _: Any) -> Any: + exchange = registry.get(venue) + fetch = exchange.fetch_markets if kind == "markets" else exchange.fetch_events + return fetch(query=query, limit=limit, cursor=cursor, status=status) + + results: dict[str, Any] = {} + errors: dict[str, ErrorDetail] = {} + for venue, answer in per_venue({venue: None for venue in chosen}, page_of).items(): + if isinstance(answer, Exception): + errors[venue] = error_of(answer, venue) + else: + results[venue] = {"data": list(answer), "next_cursor": getattr(answer, "next_cursor", None), + "count": len(answer)} + return {"results": results, "errors": errors} + + @app.get("/search/markets", dependencies=protected, tags=["batch"], summary="Search markets on several venues", + responses=responses) + def search_markets(request: Request, query: SearchQuery, venues: VenuesQuery = None, limit: LimitQuery = 20, + cursor: CursorQuery = None, status: StatusQuery = "open") -> VenueSearch[Market]: + """Each venue's own search, asked in parallel: Kalshi's and Polymarket US's search endpoints, + Polymarket's server-side text filter, and so on (`fetch_markets(query=...)`).""" + return VenueSearch[Market].model_validate(search(request, "markets", query, venues, limit, cursor, status)) + + @app.get("/search/events", dependencies=protected, tags=["batch"], summary="Search events on several venues", + responses=responses) + def search_events(request: Request, query: SearchQuery, venues: VenuesQuery = None, limit: LimitQuery = 20, + cursor: CursorQuery = None, status: StatusQuery = "open") -> VenueSearch[Event]: + """Events with their markets nested, from each venue's own search (`fetch_events(query=...)`).""" + return VenueSearch[Event].model_validate(search(request, "events", query, venues, limit, cursor, status)) + + +__all__ = ["BatchBooks", "BatchMarkets", "MAX_BATCH_IDS", "VenueSearch", "add_batch_routes"] diff --git a/synpath/server/bucket_books.py b/synpath/server/bucket_books.py new file mode 100644 index 0000000..06f3317 --- /dev/null +++ b/synpath/server/bucket_books.py @@ -0,0 +1,345 @@ +"""A bucket's merged book and its router preview, computed in `synpath serve`. + +Two things a screen needs before it routes an order on a bucket, both made +with the library's own router (`synpath.engine.router`), so what a client +shows is what an order on the bucket would see: + +* **The merged book** (`bucket:` on `/ws/market`): every member's live + book in bucket terms (`router.view`, a flipped member read through its NO + side) laid side by side by `router.merge`, each level net of its venue's + taker fee and labelled with the member it came from. The members' books are + the hub's shared venue streams; a bucket subscription opens no connection + of its own. A member switched off (`enabled=False` on the definition) is + not subscribed, adds no levels and does not hold `ready` back. +* **The plan** (`POST /trading/buckets/{id}/plan`): `router.plan` for a side, + a quantity and a worst price, on the same books, the same fees and the + venues' published precision, over the enabled members only. Nothing is placed. + +Fees are each member's published fee schedule (`fetch_fee_schedule` through +the read app's registry, the formula the Kalshi adapter's own estimate uses), +read once per member and kept for `FEE_SCHEDULE_TTL_S`. A level's fee is +quoted for `FEE_REFERENCE` contracts and divided back to one, and a venue's +per-order floor is kept apart for the plan, exactly as an order on the bucket +prices its levels (`synpath.engine.orders.routed`). A member whose fee the +library cannot price counts as free and is named in `fees_unknown`. +""" +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any, Callable + +from .. import ids +from ..bucket import Bucket, BucketMember +from ..engine import router +from ..engine.orders.routed import FEE_REFERENCE, FEE_REFERENCE_TRIES +from ..trading.types import Side +from ..ws.base import now_ms + +log = logging.getLogger("synpath.server") + +ZERO = Decimal("0") + +# How long a member's fee schedule is trusted before it is read again. +FEE_SCHEDULE_TTL_S = 600.0 + +# How long a member whose schedule could not be read counts as free before +# it is asked again. +FEE_SCHEDULE_RETRY_S = 60.0 + +# Where a member's book came from: `stream` (serve's live venue stream), +# `rest` (read from the venue for this request) or `missing`. +BookSource = str + + +def _text(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + +# --------------------------------------------------------------------------- +# Fees +# --------------------------------------------------------------------------- + +@dataclass +class _Schedule: + # The member's `FeeSchedule`, or `None` where it could not be read. + schedule: Any | None + read_at: float + + +class BucketFees: + """Each member's taker fee per contract, from its published fee schedule, + and its per-order floor. `prepare` reads what is missing (blocking venue + reads, run in a thread); everything after that is a cache lookup, so a + book can be priced on every update without a venue call.""" + + def __init__(self, registry: Any, *, ttl_s: float = FEE_SCHEDULE_TTL_S, retry_s: float = FEE_SCHEDULE_RETRY_S, + clock: Callable[[], float] = time.monotonic): + self.registry = registry + self.ttl_s, self.retry_s, self.clock = ttl_s, retry_s, clock + self.schedules: dict[str, _Schedule] = {} + self.per_contract_cache: dict[tuple[str, Decimal], Decimal | None] = {} + + async def prepare(self, bucket: Bucket) -> None: + """Read every member's schedule that is missing or stale.""" + for member in bucket.members: + if not self._fresh(member.market_id): + self._store(member.market_id, await asyncio.to_thread(self._read_schedule, member.market_id)) + + def _fresh(self, market_id: str) -> bool: + held = self.schedules.get(market_id) + if held is None: + return False + lifetime = self.ttl_s if held.schedule is not None else self.retry_s + return self.clock() - held.read_at < lifetime + + def _read_schedule(self, market_id: str) -> Any | None: + try: + return self.registry.get(ids.venue_of(market_id)).fetch_fee_schedule(market_id) + except Exception as exc: # a fee the venue will not publish is not a reason to refuse the book + log.info("synpath.server: no fee schedule for %s: %s", market_id, exc) + return None + + def _store(self, market_id: str, schedule: Any | None) -> None: + self.schedules[market_id] = _Schedule(schedule, self.clock()) + self.per_contract_cache = {key: value for key, value in self.per_contract_cache.items() + if key[0] != market_id} + + def per_contract(self, market_id: str, member_yes_price: Decimal) -> Decimal | None: + """The taker fee for one contract at the member's own YES price, or + `None` where the library cannot price it. Quoted for `FEE_REFERENCE` + contracts and divided back; while the venue's floor is all a quote + shows, a larger reference is asked for, as the routed order does.""" + key = (market_id, member_yes_price) + if key in self.per_contract_cache: + return self.per_contract_cache[key] + held = self.schedules.get(market_id) + schedule = held.schedule if held is not None else None + fee: Decimal | None = None + if schedule is not None: + reference = FEE_REFERENCE + floor = getattr(schedule, "min_fee", None) + for _ in range(FEE_REFERENCE_TRIES): + estimate = schedule.estimate(float(member_yes_price), float(reference), taker=True) + if estimate is None: + break + fee = Decimal(str(estimate)) / reference + if floor is None or Decimal(str(estimate)) > Decimal(str(floor)): + break + reference *= 100 + self.per_contract_cache[key] = fee + return fee + + def fee_function(self, bucket: Bucket, side: Side) -> router.FeeFn: + """`fee(market_id, bucket_price, contracts)` for the router, for + orders on `side` of the bucket: each level is priced at the member's + own YES price, after `flip`.""" + def fee(market_id: str, bucket_price: Decimal, contracts: Decimal) -> Decimal: + _, member_price = bucket.member(market_id).to_member(side, bucket_price) + return (self.per_contract(market_id, member_price) or ZERO) * contracts + return fee + + def floors(self, bucket: Bucket) -> dict[str, Decimal]: + """Per member, the least one taker order pays, where the venue has a floor.""" + out = {} + for member in bucket.members: + held = self.schedules.get(member.market_id) + floor = getattr(held.schedule, "min_fee", None) if held is not None and held.schedule is not None else None + if floor is not None: + out[member.market_id] = Decimal(str(floor)) + return out + + def unknown(self, bucket: Bucket) -> list[str]: + """Members whose fee the library cannot price: no schedule, or a + formula it does not know.""" + out = [] + for member in bucket.members: + held = self.schedules.get(member.market_id) + if held is None or held.schedule is None or held.schedule.estimate(0.5, 1.0) is None: + out.append(member.market_id) + return out + + +# --------------------------------------------------------------------------- +# Books +# --------------------------------------------------------------------------- + +def usable(book: Any) -> bool: + """A member book the router may read: snapshotted, still trusted, and + not crossed. The rule an engine-held order applies to its own books.""" + if book is None or getattr(book, "ready", True) is False: + return False + # A stream's `LocalBook` gives prices; a read `OrderBook` gives levels. + best_bid = getattr(book.best_bid, "price", book.best_bid) + best_ask = getattr(book.best_ask, "price", book.best_ask) + return best_bid is None or best_ask is None or best_bid <= best_ask + + +@dataclass +class MemberState: + """What a bucket subscription knows about one member beyond its book.""" + + market_id: str + # Why serve cannot stream this member (`credentials_required`, …), if it cannot. + error: str | None = None + + +def merged_levels(bucket: Bucket, views: dict[str, router.BookView], fees: BucketFees, + ) -> tuple[list[router.Level], list[router.Level]]: + """`(asks, bids)` in bucket terms, each net of its venue's taker fee for + the side it would be taken on: asks are bought, bids are sold.""" + asks, _ = router.merge(bucket, views, fees.fee_function(bucket, Side.BUY)) + _, bids = router.merge(bucket, views, fees.fee_function(bucket, Side.SELL)) + return asks, bids + + +def level_rows(levels: list[router.Level]) -> list[dict[str, Any]]: + return [{"price": str(level.price), "net_price": str(level.net_price), "size": str(level.size), + "market_id": level.market_id, "venue": ids.venue_of(level.market_id)} for level in levels] + + +def bucket_book_message(bucket: Bucket, books: dict[str, Any], fees: BucketFees, *, depth: int | None, + member_states: dict[str, MemberState] | None = None) -> dict[str, Any]: + """The `bucket_book` frame: the merged book as it stands, ready only while + every enabled member's book is usable. A bucket missing a member is not + the bucket, so it is sent with no levels rather than as a partial book. + A member switched off (`enabled=False`) is listed, with `enabled: false` + and no book, and neither adds levels nor holds `ready` back.""" + member_states = member_states or {} + members = [] + views: dict[str, router.BookView] = {} + for member in bucket.members: + if not member.enabled: + members.append({ + "market_id": member.market_id, "venue": ids.venue_of(member.market_id), "flip": member.flip, + "enabled": False, "ready": False, "sequence": None, "timestamp": None, "error": None, + }) + continue + book = books.get(member.market_id) + ready = usable(book) + if ready: + seen = router.view(book, member) + views[member.market_id] = seen if depth is None else router.BookView( + seen.market_id, seen.asks[:depth], seen.bids[:depth]) + state = member_states.get(member.market_id) + members.append({ + "market_id": member.market_id, "venue": ids.venue_of(member.market_id), "flip": member.flip, + "enabled": True, "ready": ready, "sequence": getattr(book, "sequence", None) if book is not None else None, + "timestamp": getattr(book, "timestamp", None) if book is not None else None, + "error": state.error if state is not None else None, + }) + enabled = bucket.enabled_members() + routable = bucket.model_copy(update={"members": enabled}) + ready = bool(enabled) and len(views) == len(enabled) + asks, bids = merged_levels(routable, views, fees) if ready else ([], []) + timestamps = [m["timestamp"] for m in members if m["timestamp"] is not None] + return { + "type": "bucket_book", "market_id": bucket.market_id, "bucket_id": bucket.id, "name": bucket.name, + "side": "yes", "ready": ready, + "asks": level_rows(asks), "bids": level_rows(bids), + "best_ask": _text(min((l.price for l in asks), default=None)), + "best_bid": _text(max((l.price for l in bids), default=None)), + "best_ask_net": _text(asks[0].net_price) if asks else None, + "best_bid_net": _text(bids[0].net_price) if bids else None, + "members": members, + "fees_unknown": fees.unknown(routable), + "fee_floors": {market_id: str(floor) for market_id, floor in fees.floors(routable).items()}, + "timestamp": max(timestamps) if timestamps else None, + "sent_at": now_ms(), + } + + +def bucket_trade_message(bucket: Bucket, member: BucketMember, trade: dict[str, Any]) -> dict[str, Any]: + """A member's public trade in bucket terms: a flipped member's YES trade + at p is a bucket trade at 1 - p, its taker on the other side.""" + side = trade.get("taker_side") + price = Decimal(trade["price"]) + if side in ("buy", "sell"): + bucket_side, bucket_price = member.to_bucket(Side(side), price) + side = bucket_side.value + else: + _, bucket_price = member.to_bucket(Side.BUY, price) + return {**trade, "market_id": bucket.market_id, "member_market_id": member.market_id, "flip": member.flip, + "price": str(bucket_price), "taker_side": side} + + +# --------------------------------------------------------------------------- +# The plan +# --------------------------------------------------------------------------- + +@dataclass +class PlanResult: + plan: router.Plan + sources: dict[str, BookSource] + fees_unknown: list[str] + computed_at: int = field(default_factory=now_ms) + + +class BucketBooks: + """The bucket side of serve: loads definitions from the engine's journal, + reads members' books from the `/ws/market` hub (or the venue, where no + stream holds one), and prices them with `BucketFees`.""" + + def __init__(self, engine: Any, registry: Any, hub: Any | None = None, *, fees: BucketFees | None = None): + self.engine = engine + self.registry = registry + self.hub = hub + self.fees = fees or BucketFees(registry) + + async def load(self, bucket_id: str) -> Bucket | None: + row = await self.engine.journal.bucket(bucket_id) + return Bucket.model_validate(row) if row is not None else None + + def visible_to(self, principal: Any, bucket: Bucket) -> bool: + """Whether a caller may stream this bucket: the rule every bucket route uses.""" + from .trading import sees_bucket + + return sees_bucket(principal, bucket, self.engine) + + def live_book(self, market_id: str) -> Any | None: + """The hub's maintained book for a member, if a stream holds a usable one.""" + if self.hub is None: + return None + book = self.hub.member_book(market_id) + return book if usable(book) else None + + async def current_books(self, bucket: Bucket) -> tuple[dict[str, Any], dict[str, BookSource]]: + """Every member's book now: the live one where serve streams it, else + one read from the venue for this request.""" + books: dict[str, Any] = {} + sources: dict[str, BookSource] = {} + for member in bucket.members: + book = self.live_book(member.market_id) + source = "stream" + if book is None: + book = await asyncio.to_thread(self._read_book, member.market_id) + source = "rest" if book is not None else "missing" + sources[member.market_id] = source + if book is not None: + books[member.market_id] = book + return books, sources + + def _read_book(self, market_id: str) -> Any | None: + try: + return self.registry.get(ids.venue_of(market_id)).fetch_order_book(market_id, side="yes") + except Exception as exc: + log.info("synpath.server: no book for %s: %s", market_id, exc) + return None + + async def plan(self, bucket: Bucket, *, side: Side, quantity: Decimal, worst_price: Decimal) -> PlanResult: + """`router.plan` on the members' current books, as an order on the + bucket would make it now, on its enabled members only. Nothing is placed.""" + bucket = bucket.routable() + await self.fees.prepare(bucket) + books, sources = await self.current_books(bucket) + precision = {member.market_id: router.default_precision(member.market_id) for member in bucket.members} + plan = router.plan(bucket, books, precision, self.fees.fee_function(bucket, side), side=side, + quantity=quantity, limit=worst_price, floors=self.fees.floors(bucket)) + return PlanResult(plan=plan, sources=sources, fees_unknown=self.fees.unknown(bucket)) + + +__all__ = ["BucketBooks", "BucketFees", "MemberState", "PlanResult", "bucket_book_message", "bucket_trade_message", + "merged_levels", "usable"] diff --git a/synpath/server/combo_warmer.py b/synpath/server/combo_warmer.py new file mode 100644 index 0000000..29e0192 --- /dev/null +++ b/synpath/server/combo_warmer.py @@ -0,0 +1,245 @@ +"""serve's combo boards, kept warm: built at start-up, refreshed on a timer. + +A combo venue's board (`synpath.engine.combo_board.board_for`) reads the +venue's whole combo-eligible list (Polymarket: about 5,000 markets, plus +Gamma's markets and events for them; Kalshi: every open collection's events) +and takes seconds to minutes to build. A request must never wait for that, +so serve builds each combo venue's board in the background +(`ComboBoardWarmer.loop`, one of serve's background tasks) and the board +routes (`GET /trading/combos/board`, `/combos/board/categories`) answer from +the last build: + +* **start-up**: every combo venue's first build starts at once; until it ends + the routes answer `status: "building"` with no events, at once; +* **refresh**: every `refresh_interval_s` (60 s) after the last build ended, + each board is built again, through the venue's whole list (no page cap), + sleeping `pause_s` between two venue reads; +* **the first build fills in**: Polymarket lists about 214,000 combo-eligible + markets (214 pages), so its first build takes minutes; the board so far is + served (`complete: false`) after its first 5 pages (the busiest markets) and + every 40 after. Gamma's markets and events are kept between builds + (`BoardCache`), so a refresh reads Gamma only for new markets; +* **the last good board stays**: a refresh that fails, or is still running, + leaves the last board served; `refreshing` and `error` say so. + +The warmer also names markets for the RFQ groups' skip reasons +(`labels_for`, the engine's `Labeler`): a leg on a warm board by its pick +(`Al Fayha to win`), else by its market's question from the catalogue, +shortened (`U.S. invades Iran`). + +Configured in `engine.toml`: + +```toml +[combo_board] +warm = true # false: build on request, as a bare trading app does +refresh_interval_s = 60 +pause_s = 0.1 # between two reads of one venue during a build +``` +""" +from __future__ import annotations + +import asyncio +import gc +import logging +import sys +import time +from dataclasses import dataclass +from typing import Any, Callable + +from ..engine.combo_board import BoardCache, ComboBoard, board_for, label_of, market_labels, short_question +from ..engine.rfq_group import lists_combo_markets +from ..trading.types import ComboLeg + +log = logging.getLogger("synpath.serve.combo_boards") + +REFRESH_INTERVAL_S = 60.0 +PAUSE_S = 0.1 +# The GIL switch interval while boards are warmed (`loop`). +SWITCH_INTERVAL_S = 0.0005 + + +@dataclass +class WarmBoard: + """One venue's board as the warmer holds it.""" + + board: ComboBoard | None = None + # When the board held was built, ms. + built_at: int | None = None + # How long that build took. + build_ms: int | None = None + refreshing: bool = False + # Why the last build failed, while the board held is an older one (or none). + error: str | None = None + + @property + def status(self) -> str: + return "ready" if self.board is not None else "building" + + +class ComboBoardWarmer: + """Every combo venue's board, built in the background (module doc).""" + + def __init__(self, engine: Any, registry: Any | None = None, *, refresh_interval_s: float = REFRESH_INTERVAL_S, + pause_s: float = PAUSE_S, clock: Callable[[], float] = time.time): + self.engine = engine + self.registry = registry + self.refresh_interval_s = float(refresh_interval_s) + self.pause_s = float(pause_s) + self.clock = clock + self.boards: dict[str, WarmBoard] = {} + # Per venue, `market_labels` of its last board. + self.labels: dict[str, dict[str, str]] = {} + self._builds: dict[str, asyncio.Task] = {} + # Per venue, what a build keeps for the next (Gamma's markets and events). + self.caches: dict[str, BoardCache] = {} + + # -- which venues ----------------------------------------------------------- + + def venues(self) -> list[str]: + """The engine's venues with a combo board: Kalshi's collections, or a + venue's own eligible list.""" + return [name for name, adapter in self.engine.adapters.items() + if adapter.has.get("combos") and (callable(getattr(adapter, "fetch_combo_collections", None)) + or lists_combo_markets(adapter))] + + def warms(self, venue: str) -> bool: + return venue in self.venues() + + # -- what the routes read ------------------------------------------------- + + def board(self, venue: str) -> ComboBoard: + """The venue's last board, with its `status` and `built_at`; while its + first build runs, an empty board with `status: "building"`. Never + waits: a venue not yet being built starts building here.""" + held = self.boards.get(venue) + if held is None or held.board is None: + self.ensure(venue) + return ComboBoard(venue=venue, count=0, complete=False, status="building", + error=held.error if held is not None else None) + return held.board.model_copy(update={"status": "ready", "built_at": held.built_at, "error": held.error}) + + def state(self, venue: str) -> dict[str, Any]: + held = self.boards.get(venue) or WarmBoard() + board = held.board + return {"venue": venue, "status": held.status, "built_at": held.built_at, "build_ms": held.build_ms, + "complete": board.complete if board is not None else False, "refreshing": held.refreshing, + "events": len(board.events) if board is not None else 0, "error": held.error} + + # -- building --------------------------------------------------------------- + + def ensure(self, venue: str) -> asyncio.Task: + """The venue's build: the one running, else a new one.""" + task = self._builds.get(venue) + if task is None or task.done(): + task = asyncio.get_running_loop().create_task(self.build(venue), name=f"synpath-combo-board-{venue}") + self._builds[venue] = task + return task + + async def build(self, venue: str) -> None: + """Build one venue's board through its whole list. A failure keeps + the last good board and is logged; it never raises.""" + held = self.boards.setdefault(venue, WarmBoard()) + held.refreshing = True + started = self.clock() + try: + board = await board_for(venue, self.engine.adapters[venue], self.reader(venue), pages=None, + max_events=None, pause_s=self.pause_s, + cache=self.caches.setdefault(venue, BoardCache()), + progress=lambda partial: self.partial(venue, partial)) + except Exception as exc: + held.error = f"{type(exc).__name__}: {exc}" + log.warning("synpath serve: building the %s combo board failed (%s); %s", venue, held.error, + "keeping the last board" if held.board is not None else "no board yet") + return + finally: + held.refreshing = False + held.board, held.error = board, None + held.built_at = int(self.clock() * 1000) + held.build_ms = int((self.clock() - started) * 1000) + self.labels[venue] = await asyncio.to_thread(market_labels, board) + keep_out_of_gc_scans() + log.info("synpath serve: %s combo board built: %d events from %d markets in %.1f s%s", venue, + len(board.events), board.count, held.build_ms / 1000, "" if board.complete else " (incomplete)") + + async def partial(self, venue: str, board: ComboBoard) -> None: + """The first build's board so far (`complete: false`), served until it + ends; a refresh keeps serving the last whole board instead.""" + held = self.boards.setdefault(venue, WarmBoard()) + if held.board is not None and held.board.complete: + return + held.board, held.built_at = board, int(self.clock() * 1000) + self.labels[venue] = await asyncio.to_thread(market_labels, board) + keep_out_of_gc_scans() + + async def refresh_all(self) -> None: + venues = self.venues() + await asyncio.gather(*(self.ensure(venue) for venue in venues), return_exceptions=True) + + async def loop(self) -> None: + """serve's background task: build every board now, then again each + `refresh_interval_s` after the last build ended.""" + # Builds run in worker threads (`share_the_lock`). Under the GIL, + # serve's event loop also waits up to one switch interval (5 ms by + # default) each time it takes the lock back, and one request takes it + # many times; a 0.5 ms interval keeps requests fast while a board builds. + sys.setswitchinterval(min(sys.getswitchinterval(), SWITCH_INTERVAL_S)) + while True: + try: + await self.refresh_all() + except Exception: # a board is never a reason to stop serve + log.exception("synpath serve: refreshing the combo boards failed") + await asyncio.sleep(self.refresh_interval_s) + + async def close(self) -> None: + for task in self._builds.values(): + task.cancel() + await asyncio.gather(*self._builds.values(), return_exceptions=True) + + def reader(self, venue: str) -> Any | None: + if self.registry is None: + return None + try: + return self.registry.get(venue) + except Exception: + return None + + # -- naming legs (the RFQ groups' `Labeler`) ----------------------------------- + + async def labels_for(self, legs: list[ComboLeg]) -> dict[str, str]: + """A short name for each leg's market: its pick on a warm board, else + its question from the catalogue, shortened. Legs neither knows are + left out (the group names them by id).""" + out: dict[str, str] = {} + unknown: dict[str, list[str]] = {} + for leg in legs: + venue = leg.market_id.partition(":")[0] + found = label_of(self.labels.get(venue) or {}, leg.market_id, leg.outcome) + if found: + out[leg.market_id] = found + else: + unknown.setdefault(venue, []).append(leg.market_id) + for venue, wanted in unknown.items(): + reader = self.reader(venue) + if reader is None or not callable(getattr(reader, "fetch_markets_by_ids", None)): + continue + try: + markets = await asyncio.to_thread(reader.fetch_markets_by_ids, wanted) + except Exception as exc: + log.info("synpath serve: reading %s legs' titles failed: %s", venue, exc) + continue + for market in markets: + if market.title: + out.setdefault(market.id, short_question(market.title)) + return out + + +def keep_out_of_gc_scans() -> None: + """Move every object alive now out of the cyclic collector's reach. + + A whole Polymarket board is millions of objects, and each full collection + rescans them all while every thread waits: with a board held, collections + took 0.3-1.4 s each and froze serve's requests. The board and its cache + are long-lived and acyclic, so `gc.freeze` loses nothing: reference + counting still frees them when the next build replaces them.""" + gc.freeze() + diff --git a/synpath/server/errors.py b/synpath/server/errors.py index ea9fab5..e373857 100644 --- a/synpath/server/errors.py +++ b/synpath/server/errors.py @@ -58,7 +58,7 @@ async def _on_synpath_error(request: Request, exc: SynpathError) -> JSONResponse "venue": getattr(exc, "synpath_venue", None) or _venue_of(exc), "retryable": isinstance(exc, NetworkError), } - for name in ("rule", "reason"): + for name in ("rule", "reason", "client_order_id", "collections", "skipped"): value = getattr(exc, name, None) if value: details[name] = value diff --git a/synpath/server/hosted_routes.py b/synpath/server/hosted_routes.py new file mode 100644 index 0000000..301ff57 --- /dev/null +++ b/synpath/server/hosted_routes.py @@ -0,0 +1,174 @@ +"""Synpath's hosted services through `synpath serve`: matching and tick history. + +The HTTP form of `synpath.match_market`, `synpath.match_event` and +`synpath.history` (`fetch_order_book_at`, `fetch_order_book_range`, +`fetch_trades_range`), plus the hosted arbitrage scan the matching service +publishes. A client of serve therefore needs no Synpath API key and opens no +connection to api.synpath.dev or api2.synpath.dev itself: serve makes the +call, with the key the library finds (`SYNPATH_API_KEY`, else the key +`synpath login` / `synpath keys create` saved), exactly as an in-process +caller's would be found. + +These routes are added by `synpath serve`, not by the plain read app +(`create_app`), which stays a single-venue surface with no cross-venue logic. + +Hosted failures get their own error codes, so a client can tell "serve +refused my token" (401) from "Synpath's service refused serve's key": + +| Hosted answer | Serve answers | +|---|---| +| 401 / 403 (no or bad Synpath API key) | 502 `hosted_unauthorized` | +| 404 from the service (no such listing) | 404 `market_not_found` | +| 404 from the host in front (not deployed) | 502 `hosted_unavailable`, retryable | +| 413 (result too large to return whole) | 413 `result_too_large` | +| 429 | 429 `rate_limit_exceeded`, retryable | +| timeout | 504 `hosted_timeout`, retryable | +| unreachable, 5xx | 502 `hosted_unavailable`, retryable | +""" +from __future__ import annotations + +from functools import wraps +from typing import Annotated, Any, Callable, Literal, TypeVar + +from fastapi import FastAPI, HTTPException, Query, Request + +from .. import history, matching +from ..errors import ( + AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NetworkError, RateLimitExceeded, RequestTimeout, + SynpathError, +) +from ..hosted import LABEL, call, hosted_client +from ..types import EventMatch, MarketMatch, OrderBookAtResponse, OrderBookRangeResponse, TradesRangeResponse +from .errors import http_error +from .models import ErrorBody + +R = TypeVar("R") + +HOSTED_RESPONSES: dict[int | str, dict[str, Any]] = { + 400: {"model": ErrorBody, "description": "Invalid parameters, such as an id that is not a Synpath id."}, + 404: {"model": ErrorBody, "description": "The hosted service has no such listing."}, + 413: {"model": ErrorBody, "description": "`result_too_large`: narrow the range or raise `limit`."}, + 429: {"model": ErrorBody, "description": "The hosted service's rate limit; retry after backing off."}, + 502: {"model": ErrorBody, "description": "`hosted_unauthorized` (serve's Synpath API key is missing or refused) " + "or `hosted_unavailable` (the service is down or unreachable)."}, + 504: {"model": ErrorBody, "description": "`hosted_timeout`: the service did not answer in time."}, +} + + +def hosted_failure(exc: SynpathError) -> HTTPException: + """A hosted service's failure as serve's answer (see the table above).""" + message = str(exc) + if isinstance(exc, ExchangeError) and exc.status == 413: + return http_error(413, "result_too_large", message, venue=LABEL, retryable=False) + if isinstance(exc, AuthenticationError): + return http_error(502, "hosted_unauthorized", message, venue=LABEL, retryable=False) + if isinstance(exc, MarketNotFound): + return http_error(404, "market_not_found", message, venue=LABEL, retryable=False) + if isinstance(exc, BadRequest): + return http_error(400, "bad_request", message, venue=LABEL, retryable=False) + if isinstance(exc, RateLimitExceeded): + return http_error(429, "rate_limit_exceeded", message, venue=LABEL, retryable=True) + if isinstance(exc, RequestTimeout): + return http_error(504, "hosted_timeout", message, venue=LABEL, retryable=True) + if isinstance(exc, NetworkError): + return http_error(502, "hosted_unavailable", message, venue=LABEL, retryable=True) + return http_error(502, "hosted_error", message, venue=LABEL, retryable=False) + + +def answered_by_hosted(handler: Callable[..., R]) -> Callable[..., R]: + """Run a route that calls a hosted service, turning its failures into serve's errors.""" + @wraps(handler) + def run(*args: Any, **kwargs: Any) -> R: + try: + return handler(*args, **kwargs) + except SynpathError as exc: + raise hosted_failure(exc) from None + return run + + +def hosted_http_of(request: Request) -> Any: + """The `httpx.Client` hosted calls go through, when the host injected one + (tests, or a host pooling connections); otherwise each call opens and + closes its own, as the library does.""" + return getattr(request.app.state, "hosted_http", None) + + +def check_window(start_ms: int, end_ms: int) -> None: + if end_ms <= start_ms: + raise http_error(400, "validation_error", "end_ms: must be after start_ms", field="end_ms") + + +MarketIdQuery = Annotated[str, Query(min_length=3, max_length=300, description=( + "A Synpath id (`kalshi:KXFOO-25`, `polymarket:2252244`)."))] +SideQuery = Annotated[Literal["yes", "no"] | None, Query(description="Which side the book is priced for.")] + + +def add_hosted_routes(app: FastAPI, protected: list[Any]) -> None: + """Add `/matching/...` and `/history/...` to serve's root app.""" + + @app.get("/matching/market", dependencies=protected, tags=["matching"], responses=HOSTED_RESPONSES, + summary="The same market on another venue") + @answered_by_hosted + def match_market(request: Request, id: MarketIdQuery, + venue: Annotated[str | None, Query(description=( + "Which venue to look on; default the anchor's usual counterpart."))] = None) -> MarketMatch: + """`synpath.match_market`. `matched: null` means the anchor is a real listing with nothing on the other + venue; an anchor the service does not know is a 404.""" + return matching.match_market(id, venue=venue, client=hosted_http_of(request)) + + @app.get("/matching/event", dependencies=protected, tags=["matching"], responses=HOSTED_RESPONSES, + summary="The same event on the other venues") + @answered_by_hosted + def match_event(request: Request, id: MarketIdQuery) -> EventMatch: + """`synpath.match_event`: per venue, its native event ids on the same event, or `null`.""" + return matching.match_event(id, client=hosted_http_of(request)) + + @app.get("/matching/arbitrage", dependencies=protected, tags=["matching"], responses=HOSTED_RESPONSES, + summary="The hosted cross-venue scan") + @answered_by_hosted + def arbitrage(request: Request) -> dict[str, Any]: + """The matching service's `GET /arbitrage`, passed through as it answers: `computed_at`, and `results`, + each an outcome priced as YES on one venue and NO on another (best asks, net of taker fees): + `outcome_id`, `event_title`, `outcome_label`, `close_at`, `yes` and `no` (`venue`, `native_market_id`, + `title`, `side`, `price`, `fee`, `url`), `gross_spread`, `fees`, `net_profit`.""" + injected = hosted_http_of(request) + http = hosted_client(base_url=None, env=matching.BASE_URL_ENV, default=matching.DEFAULT_URL, + http_client=injected) + return call(http, "GET", "/arbitrage", api_key=None, owned=injected is None, use_stored=True) + + @app.get("/history/order-book/at", dependencies=protected, tags=["history"], responses=HOSTED_RESPONSES, + summary="The book as it stood at a time") + @answered_by_hosted + def order_book_at(request: Request, market_id: MarketIdQuery, + as_of_ms: Annotated[int, Query(ge=0, description="Recorder receive time, Unix ms.")], + side: SideQuery = None, + depth: Annotated[int | None, Query(ge=1, le=1000)] = None) -> OrderBookAtResponse: + """`synpath.fetch_order_book_at`. `book: null` with `absence_reason` where there is no coverage.""" + return history.fetch_order_book_at(market_id, as_of_ms, side=side, depth=depth, + client=hosted_http_of(request)) + + @app.get("/history/order-book/range", dependencies=protected, tags=["history"], responses=HOSTED_RESPONSES, + summary="Every change to a book over a window") + @answered_by_hosted + def order_book_range(request: Request, market_id: MarketIdQuery, + start_ms: Annotated[int, Query(ge=0)], end_ms: Annotated[int, Query(ge=0)], + side: SideQuery = None, + limit: Annotated[int | None, Query(ge=1)] = None) -> OrderBookRangeResponse: + """`synpath.fetch_order_book_range`: segments, each from a full book; uncovered time is its own segment. + A window too large to return whole is a 413 `result_too_large`.""" + check_window(start_ms, end_ms) + return history.fetch_order_book_range(market_id, start_ms, end_ms, side=side, limit=limit, + client=hosted_http_of(request)) + + @app.get("/history/trades/range", dependencies=protected, tags=["history"], responses=HOSTED_RESPONSES, + summary="Trades over a window") + @answered_by_hosted + def trades_range(request: Request, market_id: MarketIdQuery, + start_ms: Annotated[int, Query(ge=0)], end_ms: Annotated[int, Query(ge=0)], + limit: Annotated[int | None, Query(ge=1)] = None) -> TradesRangeResponse: + """`synpath.fetch_trades_range`, with recording coverage; the metadata names the time basis.""" + check_window(start_ms, end_ms) + return history.fetch_trades_range(market_id, start_ms, end_ms, limit=limit, client=hosted_http_of(request)) + + +__all__ = ["add_hosted_routes", "hosted_failure"] diff --git a/synpath/server/market_stream.py b/synpath/server/market_stream.py new file mode 100644 index 0000000..0b40ad1 --- /dev/null +++ b/synpath/server/market_stream.py @@ -0,0 +1,745 @@ +"""`/ws/market`: live books, trades and quotes from the library's own venue streams. + +One socket per client, many markets on it. The server keeps **one venue +connection per venue** and shares it between every subscriber and the +engine's feeds, so the screen and the stops read the same book: + +* A venue the engine trades already has a market stream in + `synpath.engine.feeds.Feeds`. The hub subscribes markets on that same + object and hears its events through `Feeds.listeners`; it never opens a + second connection to that venue and never closes the engine's. +* Any other venue gets a connection the hub opens on the first subscription + (from serve's credentials, `synpath.engine.feeds.market_stream`) and closes + when the last subscriber leaves. + +Every market is reference-counted by the sockets subscribed to it. When the +last one leaves, the market is unwatched on the venue (on streams that can +unwatch; Kalshi and Polymarket US cannot, so their venue subscription stays +until the connection closes) unless the engine watches it too: the engine's +markets are held for as long as the engine holds them. + +What a client receives is the library's own state, not a re-derived book: +the stream's maintained `LocalBook` for the market (sent on subscribe, then +after every change, at most every `book_interval_s`), and its `TradeEvent`, +`QuoteEvent`, `MarketStatusEvent` and `StreamStatusEvent` as they come. The +venue stream reconnects on its own; a book it is not sure of is sent as +`ready: false` with no levels, and the fresh snapshot after a reconnect is +sent as soon as it lands. The wire format is in docs/server.md. + +A **bucket** (`bucket:`, `synpath.bucket`) subscribes like a market. The +hub subscribes each member on the shared venue connections through a +`MemberFeed`, an internal subscriber that forwards to the client's session, +and sends the members' books merged in bucket terms by the library's router +(`synpath.server.bucket_books`): one `bucket_book` frame per change, and each +member's public trades as `trade` frames in bucket terms. A bucket costs no +venue connection a market would not. +""" +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import dataclass, field +from decimal import Decimal +from typing import Any, Callable, Literal + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect + +from .. import ids +from ..bucket import Bucket, bucket_id_of, is_bucket_id +from ..ws.base import BookEvent, MarketStatusEvent, QuoteEvent, StreamStatusEvent, TradeEvent, now_ms + +log = logging.getLogger("synpath.server") + +# A client's channel name, and the stream capability that carries it. +CHANNELS: dict[str, str] = {"book": "watch_order_book", "trades": "watch_trades", "quotes": "watch_ticker"} + +# Messages a client may fall behind by before its socket is closed (1013). +OUTBOX_LIMIT = 10_000 + +CLOSE_UNAUTHORIZED = 4401 +CLOSE_TOO_SLOW = 1013 + +Side = Literal["yes", "no"] +# Builds a venue's market stream, or returns `None` where it cannot (no credentials). +StreamFactory = Callable[[str], Any] + + +class SubscribeRefused(Exception): + """A subscription the hub cannot serve; `code` goes to the client.""" + + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + + +# --------------------------------------------------------------------------- +# Wire messages +# --------------------------------------------------------------------------- + +def _text(value: Decimal | None) -> str | None: + return None if value is None else str(value) + + +def book_message(venue: str, market_id: str, side: Side, book: Any, depth: int | None) -> dict[str, Any]: + """The stream's maintained book for one market as it stands now. A book + the stream has not snapshotted, or stopped trusting, has no levels.""" + ready = book is not None and book.ready + bids, asks = book.levels(depth) if ready else ((), ()) + return { + "type": "book", "venue": venue, "market_id": market_id, "side": side, "ready": ready, + "bids": [{"price": str(level.price), "size": str(level.size)} for level in bids], + "asks": [{"price": str(level.price), "size": str(level.size)} for level in asks], + "best_bid": _text(book.best_bid) if ready else None, + "best_ask": _text(book.best_ask) if ready else None, + "sequence": book.sequence if book is not None else None, + "timestamp": book.timestamp if book is not None else None, + "sent_at": now_ms(), + } + + +def trade_message(event: TradeEvent) -> dict[str, Any]: + return { + "type": "trade", "venue": event.venue, "market_id": event.market_id, "id": event.id, + "price": str(event.price), "quantity": str(event.quantity), + "taker_side": event.taker_side.value if event.taker_side is not None else None, + "timestamp": event.timestamp, "received_at": event.received_at, + } + + +def quote_message(event: QuoteEvent) -> dict[str, Any]: + return { + "type": "quote", "venue": event.venue, "market_id": event.market_id, "side": event.side, + "bid": _text(event.bid), "ask": _text(event.ask), "bid_size": _text(event.bid_size), + "ask_size": _text(event.ask_size), "last": _text(event.last), "volume": _text(event.volume), + "open_interest": _text(event.open_interest), "timestamp": event.timestamp, "received_at": event.received_at, + } + + +def market_status_message(event: MarketStatusEvent) -> dict[str, Any]: + return { + "type": "market_status", "venue": event.venue, "market_id": event.market_id, "state": event.state, + "native": event.native, "result": event.result, "timestamp": event.timestamp, + "received_at": event.received_at, + } + + +def stream_status_message(event: StreamStatusEvent) -> dict[str, Any]: + return {"type": "status", "venue": event.venue, "state": event.state, "detail": event.detail, + "received_at": event.received_at} + + +def error_message(code: str, message: str, market_id: str | None = None) -> dict[str, Any]: + return {"type": "error", "code": code, "message": message, "market_id": market_id} + + +# --------------------------------------------------------------------------- +# One client +# --------------------------------------------------------------------------- + +@dataclass +class Subscription: + """One client's interest in one market.""" + + market_id: str + venue: str + side: Side + depth: int | None + channels: frozenset[str] + + +class Session: + """One connected client: what it subscribed, and what is waiting to be + sent to it. Events are queued in order; books are only marked dirty and + read from the stream when sent, so a fast book costs one message per + send interval, not one per venue update.""" + + def __init__(self, principal: Any | None = None) -> None: + # Who the client's access token belongs to; bucket visibility is checked against it. + self.principal = principal + self.subscriptions: dict[str, Subscription] = {} + self.outbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=OUTBOX_LIMIT) + self.dirty_books: set[str] = set() + self.wake = asyncio.Event() + self.overflowed = False + + def wants(self, market_id: str, channel: str) -> Subscription | None: + subscription = self.subscriptions.get(market_id) + return subscription if subscription is not None and channel in subscription.channels else None + + def send(self, message: dict[str, Any]) -> None: + try: + self.outbox.put_nowait(message) + except asyncio.QueueFull: + self.overflowed = True + self.wake.set() + + def mark_book_dirty(self, market_id: str) -> None: + self.dirty_books.add(market_id) + self.wake.set() + + +@dataclass(eq=False) +class BucketSubscription(Subscription): + """One client's interest in a bucket: the definition it streams, the + internal subscriber holding the members, and what is known about each + member beyond its book.""" + + bucket: Bucket = None # type: ignore[assignment] + owner: Session = None # type: ignore[assignment] + listener: "MemberFeed | None" = None + member_states: dict[str, Any] = field(default_factory=dict) + + # Held in sets by identity: two subscriptions to one bucket are two clients. + __hash__ = object.__hash__ + __eq__ = object.__eq__ + + +class MemberFeed(Session): + """The members of one bucket subscription, as the hub sees them: an + ordinary subscriber on each member market, so sharing and reference + counting are the hub's own. What it hears goes to the client's session + in bucket terms: a member's book change marks the bucket's book dirty, a + member's trade becomes a bucket trade.""" + + def __init__(self, hub: "MarketStreams", subscription: BucketSubscription): + super().__init__(subscription.owner.principal) + self.hub = hub + self.bucket_subscription = subscription + + def mark_book_dirty(self, market_id: str) -> None: + self.bucket_subscription.owner.mark_book_dirty(self.bucket_subscription.market_id) + + def send(self, message: dict[str, Any]) -> None: + from .bucket_books import MemberState, bucket_trade_message + + subscription, owner = self.bucket_subscription, self.bucket_subscription.owner + kind = message.get("type") + if kind == "subscribed": + if "book" not in message.get("channels", ()): + subscription.member_states[message["market_id"]] = MemberState(message["market_id"], "no_book_stream") + elif kind == "trade": + if "trades" in subscription.channels: + member = subscription.bucket.member(message["market_id"]) + owner.send(bucket_trade_message(subscription.bucket, member, message)) + elif kind == "error" and message.get("code") == "stream_ended": + # The hub dropped this subscriber's markets on that venue: the + # bucket is no longer whole, so the client is told and dropped, + # as for a market. + if owner.subscriptions.get(subscription.market_id) is subscription: + owner.subscriptions.pop(subscription.market_id, None) + self.hub.forget_bucket_subscription(subscription) + self.hub.release_later(self) + owner.send(error_message("stream_ended", message.get("message") or "a member's market stream ended", + subscription.market_id)) + elif kind in ("status", "market_status"): + owner.send(message) + + +# --------------------------------------------------------------------------- +# One venue connection +# --------------------------------------------------------------------------- + +@dataclass +class VenueConnection: + """One venue's market stream and who is subscribed to what on it. + + `owned` connections were opened by the hub and are closed when nobody is + left; the others belong to the engine's feeds and live as long as it.""" + + venue: str + stream: Any + owned: bool + sessions_by_market: dict[str, set[Session]] = field(default_factory=dict) + watched_channels: dict[str, set[str]] = field(default_factory=dict) + pump: asyncio.Task | None = None + + def sessions(self) -> set[Session]: + return {session for sessions in self.sessions_by_market.values() for session in sessions} + + def supported(self, channels: frozenset[str]) -> frozenset[str]: + has = getattr(self.stream, "has", None) or {} + return frozenset(c for c in channels if has.get(CHANNELS[c]) and callable(getattr(self.stream, CHANNELS[c], None))) + + async def watch(self, market_id: str, channels: frozenset[str]) -> None: + """Subscribe the channels this market is not yet watched on.""" + already = self.watched_channels.setdefault(market_id, set()) + for channel in sorted(channels - already): + await getattr(self.stream, CHANNELS[channel])([market_id]) + already.add(channel) + + async def unwatch(self, market_id: str) -> None: + self.watched_channels.pop(market_id, None) + unwatch = getattr(self.stream, "unwatch", None) + if callable(unwatch): + await unwatch([market_id]) + + +# --------------------------------------------------------------------------- +# The hub +# --------------------------------------------------------------------------- + +class MarketStreams: + """Every venue market connection the server's sockets use, shared and + reference-counted. See the module docstring for the sharing rules.""" + + def __init__(self, open_stream: StreamFactory, *, feeds: Any | None = None, enabled: bool = True, + book_interval_s: float = 0.1): + self.open_stream = open_stream + self.feeds = feeds + self.enabled = enabled + self.book_interval_s = book_interval_s + self.connections: dict[str, VenueConnection] = {} + self.venue_locks: dict[str, asyncio.Lock] = {} + self.closing = False + self.releasing: set[asyncio.Task] = set() + # `synpath.server.bucket_books.BucketBooks`, where the server holds buckets. + self.buckets: Any | None = None + # Bucket id -> every client subscription streaming it, for a definition change. + self.bucket_subscriptions: dict[str, set[BucketSubscription]] = {} + # Venue -> the last lifecycle event its market connection reported, for `venue_status`. + self.last_stream_status: dict[str, StreamStatusEvent] = {} + if feeds is not None: + self.adopt_engine_streams(feeds) + + def adopt_engine_streams(self, feeds: Any) -> None: + """Share the engine's market streams instead of opening our own.""" + for venue, pair in feeds.streams.items(): + if pair.market is not None: + self.connections[venue] = VenueConnection(venue=venue, stream=pair.market, owned=False) + feeds.listeners.append(self.publish) + + # -- subscribing ---------------------------------------------------------- + + async def subscribe(self, session: Session, market_id: str, channels: frozenset[str], side: Side, + depth: int | None) -> Subscription: + """Add one market to a session: open or reuse the venue's connection, + watch what is not yet watched, then queue the acknowledgement and the + current book. A bucket id subscribes the bucket's members instead.""" + if is_bucket_id(market_id): + return await self.subscribe_bucket(session, market_id, channels, side, depth) + venue = self.venue_of(market_id) + async with self.venue_lock(venue): + connection = await self.connection_for(venue) + granted = connection.supported(channels) + try: + await connection.watch(market_id, granted) + except Exception as exc: + await self.release_if_unused(connection, market_id) + raise SubscribeRefused("subscribe_failed", f"{venue} refused {market_id}: {exc}") from exc + connection.sessions_by_market.setdefault(market_id, set()).add(session) + subscription = Subscription(market_id=market_id, venue=venue, side=side, depth=depth, channels=granted) + session.subscriptions[market_id] = subscription + session.send({"type": "subscribed", "venue": venue, "market_id": market_id, "side": side, + "channels": sorted(granted), "unsupported": sorted(channels - granted)}) + if "book" in granted: + session.mark_book_dirty(market_id) + return subscription + + async def unsubscribe(self, session: Session, market_id: str) -> bool: + subscription = session.subscriptions.pop(market_id, None) + if subscription is None: + return False + if isinstance(subscription, BucketSubscription): + self.forget_bucket_subscription(subscription) + session.dirty_books.discard(market_id) + if subscription.listener is not None: + await self.leave(subscription.listener) + return True + async with self.venue_lock(subscription.venue): + connection = self.connections.get(subscription.venue) + if connection is not None: + connection.sessions_by_market.get(market_id, set()).discard(session) + await self.release_if_unused(connection, market_id) + session.dirty_books.discard(market_id) + return True + + # -- buckets -------------------------------------------------------------- + + async def subscribe_bucket(self, session: Session, market_id: str, channels: frozenset[str], side: Side, + depth: int | None) -> Subscription: + """Stream a bucket: check it exists and the client may see it, then + subscribe its members on the shared connections and queue the + acknowledgement and the merged book. `depth` is levels per member + and side; the merged book holds every one of them.""" + if self.buckets is None: + raise SubscribeRefused("buckets_unavailable", "this server holds no buckets (no engine)") + if side != "yes": + raise SubscribeRefused("bad_request", "a bucket's book is already in bucket terms: subscribe with side `yes`") + bucket = await self.buckets.load(bucket_id_of(market_id)) + if bucket is None: + raise SubscribeRefused("bucket_not_found", f"no bucket {market_id}") + if session.principal is not None and not self.buckets.visible_to(session.principal, bucket): + raise SubscribeRefused("forbidden", "this key may not view every venue in that bucket") + if market_id in session.subscriptions: + await self.unsubscribe(session, market_id) + granted = channels & frozenset({"book", "trades"}) + subscription = BucketSubscription(market_id=market_id, venue="bucket", side=side, depth=depth, + channels=granted, bucket=bucket, owner=session) + session.subscriptions[market_id] = subscription + self.bucket_subscriptions.setdefault(bucket.id, set()).add(subscription) + session.send({"type": "subscribed", "venue": "bucket", "market_id": market_id, "side": side, + "channels": sorted(granted), "unsupported": sorted(channels - granted), + "bucket": bucket.model_dump(mode="json")}) + await self.attach_members(subscription) + return subscription + + async def attach_members(self, subscription: BucketSubscription) -> None: + """Read the members' fee schedules, then subscribe every member's book + (and trades, if asked) through a fresh `MemberFeed`. A member the hub + cannot stream is recorded and reported; the bucket stays not ready. + A member switched off (`enabled=False`) is not subscribed at all.""" + from .bucket_books import MemberState + + bucket, owner = subscription.bucket, subscription.owner + routable = [member for member in bucket.members if member.enabled] + await self.buckets.fees.prepare(bucket.model_copy(update={"members": routable})) + listener = MemberFeed(self, subscription) + subscription.listener = listener + member_channels = frozenset({"book"} | ({"trades"} if "trades" in subscription.channels else set())) + for member in routable: + try: + await self.subscribe(listener, member.market_id, member_channels, "yes", None) + except SubscribeRefused as exc: + subscription.member_states[member.market_id] = MemberState(member.market_id, exc.code) + owner.send(error_message(exc.code, f"{member.market_id}: {exc}", subscription.market_id)) + owner.mark_book_dirty(subscription.market_id) + + async def refresh_bucket(self, bucket: Bucket) -> None: + """A bucket's definition changed (renamed, members edited or switched + on/off, archived or restored): every client streaming it moves to the + new definition, members added or switched on are subscribed and members + dropped or switched off released.""" + for subscription in list(self.bucket_subscriptions.get(bucket.id, ())): + old = subscription.listener + subscription.bucket = bucket + subscription.member_states = {} + await self.attach_members(subscription) + if old is not None: + await self.leave(old) + + def forget_bucket_subscription(self, subscription: BucketSubscription) -> None: + held = self.bucket_subscriptions.get(subscription.bucket.id) + if held is not None: + held.discard(subscription) + if not held: + del self.bucket_subscriptions[subscription.bucket.id] + + def member_book(self, market_id: str) -> Any | None: + """The shared stream's maintained YES book for a market, if a + connection to its venue holds one.""" + venue, _ = ids.split(market_id) + connection = self.connections.get(venue) if venue else None + return connection.stream.book(market_id) if connection is not None else None + + def release_later(self, session: Session) -> None: + task = asyncio.get_running_loop().create_task(self.leave(session), name="synpath-market-release") + self.releasing.add(task) + task.add_done_callback(self.releasing.discard) + + async def leave(self, session: Session) -> None: + """A client went away: drop everything it held.""" + for market_id in list(session.subscriptions): + await self.unsubscribe(session, market_id) + + def venue_of(self, market_id: str) -> str: + venue, native = ids.split(market_id) + if venue is None or not native: + raise SubscribeRefused("bad_market_id", f"{market_id!r} is not a Synpath id such as `kalshi:KXFOO-25`") + return venue + + def venue_lock(self, venue: str) -> asyncio.Lock: + return self.venue_locks.setdefault(venue, asyncio.Lock()) + + async def connection_for(self, venue: str) -> VenueConnection: + """The venue's shared connection, opening one if nobody has yet.""" + if not self.enabled: + raise SubscribeRefused("streams_disabled", "this server runs with --no-streams; live market data is off") + connection = self.connections.get(venue) + if connection is not None: + return connection + stream = self.open_stream(venue) + if stream is None: + raise SubscribeRefused( + "credentials_required", + f"live {venue} market data needs {venue} credentials in the server's environment or .env " + "(see `synpath doctor`), or the venue has no market stream", + ) + connection = VenueConnection(venue=venue, stream=stream, owned=True) + stream.start() + connection.pump = asyncio.get_running_loop().create_task(self.pump(connection), name=f"synpath-market-{venue}") + self.connections[venue] = connection + return connection + + async def release_if_unused(self, connection: VenueConnection, market_id: str) -> None: + """The last subscriber left a market: unwatch it unless the engine + watches it, and close a connection of our own nobody uses.""" + if connection.sessions_by_market.get(market_id): + return + connection.sessions_by_market.pop(market_id, None) + if not self.engine_watches(connection.venue, market_id): + try: + await connection.unwatch(market_id) + except Exception as exc: + log.warning("synpath.server: unwatching %s on %s failed: %s", market_id, connection.venue, exc) + if connection.owned and not connection.sessions_by_market: + await self.close_connection(connection) + + def engine_watches(self, venue: str, market_id: str) -> bool: + return self.feeds is not None and market_id in self.feeds.watching.get(venue, ()) + + async def close_connection(self, connection: VenueConnection) -> None: + if self.connections.get(connection.venue) is connection: + del self.connections[connection.venue] + try: + await connection.stream.close() + except Exception as exc: + log.debug("synpath.server: closing the %s market stream failed: %s", connection.venue, exc) + if connection.pump is not None and connection.pump is not asyncio.current_task(): + connection.pump.cancel() + await asyncio.gather(connection.pump, return_exceptions=True) + + # -- delivering ----------------------------------------------------------- + + async def pump(self, connection: VenueConnection) -> None: + """Deliver a connection of our own until it ends. The stream + reconnects by itself; it ends only when closed or refused for good, + and then its subscribers are told and dropped, so their next + subscribe opens a fresh connection.""" + async for event in connection.stream: + self.publish(connection.venue, connection.stream, event) + if self.closing or self.connections.get(connection.venue) is not connection: + return + del self.connections[connection.venue] + for session in connection.sessions(): + for market_id in list(connection.sessions_by_market): + session.subscriptions.pop(market_id, None) + session.send(error_message("stream_ended", f"the {connection.venue} market stream ended; subscribe again")) + + def publish(self, venue: str, stream: Any, event: Any) -> None: + """Route one venue event to the sessions that asked for it. Never + blocks: it only queues messages and marks books dirty.""" + connection = self.connections.get(venue) + if connection is None or connection.stream is not stream: + return + if isinstance(event, BookEvent): + for session in connection.sessions_by_market.get(event.market_id, ()): + if session.wants(event.market_id, "book"): + session.mark_book_dirty(event.market_id) + elif isinstance(event, TradeEvent): + self.send_to_market(connection, event.market_id, "trades", trade_message(event)) + elif isinstance(event, QuoteEvent): + for session in connection.sessions_by_market.get(event.market_id, ()): + subscription = session.wants(event.market_id, "quotes") + if subscription is not None and subscription.side == event.side: + session.send(quote_message(event)) + elif isinstance(event, MarketStatusEvent): + for session in connection.sessions_by_market.get(event.market_id, ()): + session.send(market_status_message(event)) + elif isinstance(event, StreamStatusEvent): + self.last_stream_status[venue] = event + self.send_stream_status(connection, event) + + def venue_status(self) -> list[dict[str, Any]]: + """Each open venue market connection's state, for a client's status bar. + + `connected` while the socket is up; `reconnecting` after it dropped or + failed to connect (the stream retries by itself); `connecting` before + it has reported anything; `failed` when the venue refused it for good. + A venue with no open connection is not listed: nothing streams it.""" + out = [] + for venue, connection in sorted(self.connections.items()): + last = self.last_stream_status.get(venue) + up = getattr(connection.stream, "connected", None) + if last is not None and last.state == "failed": + state = "failed" + elif isinstance(up, asyncio.Event) and up.is_set(): + state = "connected" + elif last is None: + state = "connecting" + elif last.state in ("connected", "subscribed", "resynced"): + state = "connected" + else: + state = "reconnecting" + out.append({"venue": venue, "state": state, "detail": last.detail if last is not None else "", + "since_ms": last.received_at if last is not None else None, + "markets": len(connection.watched_channels)}) + return out + + def send_to_market(self, connection: VenueConnection, market_id: str, channel: str, message: dict[str, Any]) -> None: + for session in connection.sessions_by_market.get(market_id, ()): + if session.wants(market_id, channel): + session.send(message) + + def send_stream_status(self, connection: VenueConnection, event: StreamStatusEvent) -> None: + """The connection's state to everyone on it, and every book re-sent: + after a drop or a gap the stream no longer trusts its books (they go + out as not ready), and after a reconnect the fresh snapshots follow.""" + message = stream_status_message(event) + for market_id, sessions in connection.sessions_by_market.items(): + for session in sessions: + if session.wants(market_id, "book"): + session.mark_book_dirty(market_id) + for session in connection.sessions(): + session.send(message) + + def book_now(self, subscription: Subscription) -> dict[str, Any]: + if isinstance(subscription, BucketSubscription): + return self.bucket_book_now(subscription) + connection = self.connections.get(subscription.venue) + book = connection.stream.book(subscription.market_id, subscription.side) if connection is not None else None + return book_message(subscription.venue, subscription.market_id, subscription.side, book, subscription.depth) + + def bucket_book_now(self, subscription: BucketSubscription) -> dict[str, Any]: + from .bucket_books import bucket_book_message + + bucket = subscription.bucket + books = {member.market_id: self.member_book(member.market_id) for member in bucket.enabled_members()} + return bucket_book_message(bucket, books, self.buckets.fees, depth=subscription.depth, + member_states=subscription.member_states) + + async def close(self) -> None: + self.closing = True + await asyncio.gather(*self.releasing, return_exceptions=True) + for connection in list(self.connections.values()): + if connection.owned: + await self.close_connection(connection) + self.connections.clear() + + +# --------------------------------------------------------------------------- +# The socket +# --------------------------------------------------------------------------- + +def parse_subscribe(message: dict[str, Any]) -> tuple[str, frozenset[str], Side, int | None]: + """A subscribe request's market, channels, side and depth, or `SubscribeRefused`.""" + market_id = message.get("market_id") + if not isinstance(market_id, str) or not market_id: + raise SubscribeRefused("bad_request", "subscribe needs `market_id`") + channels = message.get("channels") or ["book"] + if isinstance(channels, str): + channels = [c for c in channels.split(",") if c] + unknown = set(channels) - set(CHANNELS) + if unknown: + raise SubscribeRefused("bad_request", f"unknown channels {sorted(unknown)}; choose from {sorted(CHANNELS)}") + side = message.get("side") or "yes" + if side not in ("yes", "no"): + raise SubscribeRefused("bad_request", "`side` is `yes` or `no`") + depth = message.get("depth") + if depth is not None and (not isinstance(depth, int) or isinstance(depth, bool) or depth < 1): + raise SubscribeRefused("bad_request", "`depth` is a positive integer") + return market_id, frozenset(channels), side, depth + + +async def handle_request(hub: MarketStreams, session: Session, message: Any) -> None: + """One client frame: `{"op": "subscribe", ...}` or `{"op": "unsubscribe", "market_id"}`.""" + if not isinstance(message, dict): + session.send(error_message("bad_request", "send a JSON object with `op`")) + return + op = message.get("op") + market_id = message.get("market_id") if isinstance(message.get("market_id"), str) else None + try: + if op == "subscribe": + await hub.subscribe(session, *parse_subscribe(message)) + elif op == "unsubscribe": + if await hub.unsubscribe(session, market_id or ""): + session.send({"type": "unsubscribed", "market_id": market_id}) + else: + session.send(error_message("not_subscribed", f"{market_id!r} is not subscribed here", market_id)) + else: + session.send(error_message("bad_request", "`op` is `subscribe` or `unsubscribe`")) + except SubscribeRefused as exc: + session.send(error_message(exc.code, str(exc), market_id)) + + +async def receive_requests(socket: WebSocket, hub: MarketStreams, session: Session) -> None: + while True: + raw = await socket.receive_text() + try: + message = json.loads(raw) + except ValueError: + session.send(error_message("bad_request", "frames are JSON")) + continue + await handle_request(hub, session, message) + + +async def send_updates(socket: WebSocket, hub: MarketStreams, session: Session) -> None: + """Queued events in order, then every dirty book as it stands now; then + wait at least `book_interval_s`, so books coalesce on a busy market.""" + while True: + await session.wake.wait() + session.wake.clear() + if session.overflowed: + await socket.close(code=CLOSE_TOO_SLOW, reason="this client fell too far behind") + return + while not session.outbox.empty(): + await socket.send_text(json.dumps(session.outbox.get_nowait())) + dirty, session.dirty_books = session.dirty_books, set() + for market_id in dirty: + subscription = session.subscriptions.get(market_id) + if subscription is not None: + await socket.send_text(json.dumps(hub.book_now(subscription))) + if hub.book_interval_s: + await asyncio.sleep(hub.book_interval_s) + + +def initial_requests(socket: WebSocket) -> list[dict[str, Any]]: + """Subscriptions named on the URL: `?market_id=…&channels=book,trades&side=yes&depth=16`, + `market_id` repeatable. The socket then takes the same as messages.""" + params = socket.query_params + depth = params.get("depth") + common = {"channels": params.get("channels") or "book", "side": params.get("side") or "yes", + "depth": int(depth) if depth and depth.isdigit() else depth} + return [{"op": "subscribe", "market_id": market_id, **common} for market_id in params.getlist("market_id")] + + +def add_market_socket(app: FastAPI, hub: MarketStreams) -> None: + """`/ws/market` on `app`, authenticated like `/trading/ws/events`: the + access token as `?key=` or `Authorization: Bearer`, any valid key.""" + from fastapi import Depends + + from .trading import caller, socket_principal + + app.state.market_streams = hub + + @app.get("/market/streams", tags=["market"], summary="Each venue's market connection", + dependencies=[Depends(caller)]) + async def market_streams_status() -> list[dict[str, Any]]: + """Serve's open venue market connections and their state (see + `MarketStreams.venue_status`). A venue not listed has no connection.""" + return hub.venue_status() + + @app.websocket("/ws/market") + async def market(socket: WebSocket) -> None: + # Accept first, then close with the reason: a close before accepting + # becomes an HTTP 403 on the handshake, and a browser cannot read why. + await socket.accept() + store = getattr(socket.app.state, "control", None) + principal = await socket_principal(socket, store, socket.query_params.get("key")) if store else None + if principal is None: + await socket.close(code=CLOSE_UNAUTHORIZED, reason="a valid access token is required") + return + await serve_session(socket, hub, principal) + + +async def serve_session(socket: WebSocket, hub: MarketStreams, principal: Any | None = None) -> None: + """Run one client until it leaves; then release everything it held.""" + session = Session(principal) + sender = asyncio.create_task(send_updates(socket, hub, session)) + try: + for request in initial_requests(socket): + await handle_request(hub, session, request) + receiver = asyncio.create_task(receive_requests(socket, hub, session)) + done, _ = await asyncio.wait({sender, receiver}, return_when=asyncio.FIRST_COMPLETED) + receiver.cancel() + await asyncio.gather(receiver, return_exceptions=True) + except WebSocketDisconnect: + pass + except Exception: # pragma: no cover - a broken socket is not a venue problem + log.debug("synpath.server: the market socket ended", exc_info=True) + finally: + sender.cancel() + # Released in a task of its own: when the server cancels this handler + # (shutdown, a test client closing) the release must still finish, + # and an await here would be cancelled with the handler. + hub.release_later(session) diff --git a/synpath/server/order_access.py b/synpath/server/order_access.py new file mode 100644 index 0000000..13d7aea --- /dev/null +++ b/synpath/server/order_access.py @@ -0,0 +1,73 @@ +"""Shared account authorization for order responses and their audit events.""" +from __future__ import annotations + +from ..engine.execution_scope import MANAGED_NAMESPACE, order_accounts +from ..trading.types import Account, Order + + +def may_view_account(principal, account: str) -> bool: + return principal.may("view", account) or principal.may("trade", account) + + +def may_view_order(principal, order: Order) -> bool: + return all(may_view_account(principal, account) for account in order_accounts(order)) + + +def may_trade_order(principal, order: Order) -> bool: + return all(principal.may("trade", account) for account in order_accounts(order)) + + +async def may_view_event(principal, engine, event) -> bool: + """Resolve legacy parent events through durable identity, never nominal account. + + New snapshots carry scope inline. Old append-only events are unchanged; + when their parent cannot be identified safely, only a global viewer sees + them. Individual child events remain visible on their concrete account. + """ + body = event.payload + request = body.get("effective_request") or body.get("request") or {} + if event.kind.startswith("submission."): + reservation = await engine.journal.submission(event.key) if event.key else None + if reservation and reservation["venue"] == MANAGED_NAMESPACE: + captured = reservation["effective_request"]["params"]["execution_accounts"] + return all(may_view_account(principal, Account.model_validate(account).key) for account in captured.values()) + is_parent = event.kind.startswith("managed.") or body.get("held_by") == "engine" + # Request params are caller-controlled, even when recorded in an audit + # event. Only engine-produced order snapshots or stored reservations own scope. + scope = body.get("execution_accounts") + if scope and is_parent: + return all(may_view_account(principal, Account.model_validate(account).key) for account in scope.values()) + if is_parent: + parent_id = body.get("parent_id") or body.get("id") or event.key + if parent_id and parent_id.startswith(f"{MANAGED_NAMESPACE}:"): + parent_id = parent_id.split(":", 1)[1] + stored = await engine.journal.managed_one(parent_id) if parent_id else None + if stored: + parent = engine._rebuilt(*stored) + return parent is not None and may_view_order(principal, parent.as_order()) + return principal.may("view", "*") + account = body.get("account") or request.get("account") + if isinstance(account, dict): + return may_view_account(principal, Account.model_validate(account).key) + if isinstance(account, str) and account != MANAGED_NAMESPACE: + return may_view_account(principal, account) + if scope: + return all(may_view_account(principal, Account.model_validate(account).key) for account in scope.values()) + parent_id = body.get("parent_id") or (request.get("tags") or {}).get("parent") + if parent_id: + stored = await engine.journal.managed_one(parent_id) + if stored: + parent = engine._rebuilt(*stored) + return parent is not None and may_view_order(principal, parent.as_order()) + return principal.may("view", "*") + if event.kind.startswith(("order.", "fill.", "intent.", "submission.", "risk.")): + # Legacy order events sometimes name only their client/native ID. + key = body.get("client_order_id") or event.key + order = await engine.journal.order_by_client_id(key) if key else None + if order is None and key: + try: + order = await engine._known(key.split(":", 1)[-1], None) + except Exception: + pass + return may_view_order(principal, order) if order else principal.may("view", "*") + return True diff --git a/synpath/server/rfq_routes.py b/synpath/server/rfq_routes.py new file mode 100644 index 0000000..209a24b --- /dev/null +++ b/synpath/server/rfq_routes.py @@ -0,0 +1,926 @@ +"""RFQ and combo routes under `/trading` (docs/rfq-combo.md §6, phase P5). + +Thin over the engine's desk (`engine.rfq`, `synpath/engine/rfq.py`): every +write goes through the `Engine` methods, so risk, the journal, the halt and +recovery are the engine's, exactly as for `POST /orders`. What this layer +adds is who may ask and on which account, and the status codes. + +**An accept or a confirm answers `202` by default.** The venue answers an +accept with the quote `accepted`; the maker's last look and the execution +come later, as `quote.*` events on `/ws/events`. With `wait: true` the route +follows the quote for up to `timeout_s` and answers `200` when it is final +(`executed`, `canceled`, `expired` or `failed`), `202` when it is not yet. A +send whose outcome is not known is `202 submission_in_doubt` with the intent +key in `details.client_order_id`, as for orders. + +**The venue of an id.** RFQ and quote ids are the venue's own, not Synpath +ids, so a route finds the venue from `venue` (query or body), the account +named, a market id (`venue:native`), what the engine already holds about the +id, or the only venue that has the capability; otherwise it is a `400`. The +grant is checked on that venue's account before anything is sent. +""" +from __future__ import annotations + +import time +from decimal import Decimal +from typing import Annotated, Any, Awaitable, Callable, Literal + +from fastapi import APIRouter, HTTPException, Path, Query, Request, Response +from pydantic import BaseModel, Field, model_validator + +from .. import ids +from ..engine.engine import Engine +from ..errors import SynpathError +from ..trading.types import ( + AcceptQuote, Account, Action, Combo, ComboLeg, ComboMarket, MakerQuote, Outcome, QuoteStatus, Rfq, RfqQuote, RfqStatus, +) +from .errors import http_error +from .models import ErrorBody, PageResponse +from .store import Principal +from .trading import RESPONSES, Caller, EngineDep, _tag_venue, account_key, require, visible + +RFQ_RESPONSES: dict[int | str, dict[str, Any]] = { + **RESPONSES, + 404: {"model": ErrorBody, "description": "No such RFQ, quote or combo, or no such venue"}, + 409: {"model": ErrorBody, "description": "Refused by a pre-trade risk rule (`details.rule`) or the halt"}, + 501: {"model": ErrorBody, "description": "The venue has no such call"}, +} + + + +# --------------------------------------------------------------------------- +# Wire shapes +# --------------------------------------------------------------------------- + +class RfqBody(BaseModel): + """An RFQ to create: one market or one combo, sized by contracts or money.""" + + market_id: str = Field(description="The market, or the combo's own market id (`venue:native`)") + quantity: Decimal | None = Field(default=None, gt=0, description="Contracts. Exactly one of this and `amount`") + amount: Decimal | None = Field(default=None, gt=0, description="Money, in the venue's collateral") + outcome: Outcome | None = Field(default=None, description="Only where the venue asks one side") + action: Action | None = None + rest_remainder: bool = False + combo: Combo | None = Field(default=None, description="The combo's legs, where known; defaults to the combo the " + "engine saved under `market_id`") + venue: str | None = None + account: Account | None = None + book: str | None = None + trader: str | None = None + tags: dict[str, str] = Field(default_factory=dict) + + @model_validator(mode="after") + def _one_size(self) -> "RfqBody": + if (self.quantity is None) == (self.amount is None): + raise ValueError("size an RFQ by quantity (contracts) or amount (money), exactly one") + return self + + +class RfqView(Rfq): + """An RFQ, with the strategy book and trader the engine holds for it (ours only).""" + + book: str | None = None + trader: str | None = None + + +class AcceptBody(AcceptQuote): + """`AcceptQuote`, with the quote id from the path, plus how long to follow it.""" + + quote_id: str | None = Field(default=None, description="Taken from the path; ignored here") + venue: str | None = None + wait: bool = Field(default=False, description="Follow the quote until it is final, up to `timeout_s`") + timeout_s: float = Field(default=30.0, gt=0, le=120) + + +class ConfirmBody(BaseModel): + """The maker's last look on one of our accepted quotes.""" + + rfq_id: str | None = None + venue: str | None = None + wait: bool = False + timeout_s: float = Field(default=30.0, gt=0, le=120) + + +class MakerQuoteBody(MakerQuote): + """`MakerQuote`, plus the venue it is for.""" + + venue: str | None = None + + +class ComboBody(BaseModel): + """The legs of a combo. The same legs always give the same combo.""" + + legs: list[ComboLeg] = Field(min_length=2) + venue: str | None = None + + +class CreateComboBody(ComboBody): + """`ComboBody`, plus where to build the combo.""" + + collection_id: str | None = Field( + default=None, description="Kalshi: the multivariate event collection to build the combo in " + "(`collection_ticker`, e.g. `KXMVECROSSCATEGORY-R`; see `GET /combos/collections`). Without it the one " + "open collection that takes every leg's event is used, and several are a `400` naming them in " + "`details.collections`. Refused (`400`) on a venue without collections (Polymarket, Polymarket US).") + + +class QuoteResult(BaseModel): + """What an accept or a confirm did, as far as it is known yet.""" + + quote: RfqQuote + client_order_id: str = Field(description="The intent key: retry under it, follow events by it") + venue: str + final: bool = Field(description="The quote is `executed`, `canceled`, `expired` or `failed`") + intent_state: str | None = Field(default=None, description="`sending` until the quote is final, then `sent` " + "(traded) or `failed`/`rejected` (nothing traded)") + order_id: str | None = Field(default=None, description="The order the execution made, once adopted") + + +ACCEPT_RESPONSES: dict[int | str, dict[str, Any]] = { + **RFQ_RESPONSES, + 200: {"model": QuoteResult, "description": "`wait` was true and the quote is final"}, + 202: {"description": "Sent; the result arrives as `quote.*` events. Also the error envelope " + "`submission_in_doubt` (with `details.client_order_id`) when the venue gave no answer"}, +} + + +class Canceled(BaseModel): + id: str + venue: str + kind: Literal["rfq", "quote"] + status: Literal["canceled"] = "canceled" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def rfq_venue(engine: Engine, venue: str | None = None, *, account: Account | None = None, + market_id: str | None = None, rfq_id: str | None = None, quote_id: str | None = None, + capability: str | None = None) -> str: + """The venue a request is for (module docstring).""" + if venue: + if venue not in engine.adapters: + raise http_error(404, "unknown_venue", f"this server does not trade {venue}", venue=venue) + return venue + if account is not None: + return account.venue + desk = engine.rfq + if rfq_id: + for key in (*desk.rfqs, *desk.incoming): + if key.endswith(f":{rfq_id}"): + return key.split(":", 1)[0] + if quote_id: + for key in desk.maker_quotes: + if key.endswith(f":{quote_id}"): + return key.split(":", 1)[0] + for key in (*desk.following, *desk._last_quote): + if key.endswith(f":quote:{quote_id}"): + return key.split(":", 1)[0] + if market_id: + found, _ = ids.split(market_id) + if found is not None and found in engine.adapters: + return found + capable = [v for v, a in engine.adapters.items() if capability is None or a.has.get(capability)] + if len(capable) == 1: + return capable[0] + if len(engine.adapters) == 1: + return next(iter(engine.adapters)) + raise HTTPException(status_code=400, detail="name the venue: pass `venue`, name an account, or use a Synpath " + "id (venue:native)") + + +def venues_for(engine: Engine, principal: Principal, venue: str | None, capability: str) -> list[str]: + """The venues a list route reads: the one named (403 if the key may not + view it), else every venue with the capability the key may view.""" + if venue: + rfq_venue(engine, venue) + if not visible(principal, account_key(None, engine, venue)): + raise HTTPException(status_code=403, detail=f"this key may not view {account_key(None, engine, venue)}") + return [venue] + return [v for v, a in engine.adapters.items() + if a.has.get(capability) and visible(principal, account_key(None, engine, v))] + + +def require_view(principal: Principal, engine: Engine, venue: str) -> None: + account = account_key(None, engine, venue) + if not visible(principal, account): + raise HTTPException(status_code=403, detail=f"this key may not view {account}") + + +async def tagged(venue: str, awaitable: Any) -> Any: + try: + return await awaitable + except SynpathError as exc: + _tag_venue(exc, venue) + raise + + +def rfq_view(rfq: Rfq, held: dict[str, Any] | None) -> RfqView: + return RfqView.model_validate({**rfq.model_dump(), "book": (held or {}).get("book"), + "trader": (held or {}).get("trader")}) + + +async def quote_result(engine: Engine, quote: RfqQuote, key: str, venue: str) -> QuoteResult: + intent = await engine.journal.intent(key) + return QuoteResult(quote=quote, client_order_id=key, venue=venue, final=quote.is_terminal, + intent_state=intent.state if intent else None, + order_id=(intent.order_id if intent else None) or None) + + +# How long a combo list read (eligible markets, collections) is served from +# memory: the terminal polls them, and they change on the scale of minutes. +COMBO_LIST_TTL_S = 15.0 +COMBO_LIST_CACHE_SIZE = 256 +# How long a venue's combo board is served from memory: it reads several +# pages of the venue's list (and, on Kalshi, every collection event's markets). +COMBO_BOARD_TTL_S = 30.0 +# How long a group preview is reused: the slip asks again on every pick. +PREVIEW_TTL_S = 10.0 + + +class TtlCache: + """A small in-process cache of venue reads, by key, for `ttl_s` seconds. + Only answers are kept; an error is never cached.""" + + def __init__(self, ttl_s: float = COMBO_LIST_TTL_S, size: int = COMBO_LIST_CACHE_SIZE, + clock: Callable[[], float] = time.monotonic): + self.ttl_s, self.size, self.clock = ttl_s, size, clock + self.rows: dict[Any, tuple[float, Any]] = {} + + async def get(self, key: Any, read: Callable[[], Awaitable[Any]]) -> Any: + now = self.clock() + held = self.rows.get(key) + if held is not None and held[0] > now: + return held[1] + value = await read() + if len(self.rows) >= self.size: + for stale in [k for k, (until, _) in self.rows.items() if until <= now] or [next(iter(self.rows))]: + self.rows.pop(stale, None) + self.rows[key] = (now + self.ttl_s, value) + return value + + +from ..engine.rfq_group import lists_combo_markets # noqa: E402 (shared with the engine's groups) + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + +def add_rfq_routes(router: APIRouter) -> None: + """RFQs (taker and quoter sides), quotes and combos.""" + + combo_lists = TtlCache() + + # -- taker: RFQs ---------------------------------------------------------- + + @router.post("/rfqs", tags=["rfq"], summary="Ask for quotes", responses=RFQ_RESPONSES, status_code=201) + async def create_rfq(principal: Caller, engine: EngineDep, body: RfqBody) -> RfqView: + """On a market or a combo (its own market id), by contracts or money. + Journaled; refused (`409`, `details.rule`) while halted, on a paused + book, a restricted market or over the order size limits. Quotes + arrive as `quote.received` events and on `GET /rfqs/{id}/quotes`.""" + venue = rfq_venue(engine, body.venue, account=body.account, market_id=body.market_id, capability="rfq_taker") + require(principal, "trade", account_key(body.account, engine, venue)) + combo = body.combo or await engine.rfq.combo(body.market_id) + rfq = await tagged(venue, engine.create_rfq( + body.market_id, quantity=body.quantity, amount=body.amount, outcome=body.outcome, action=body.action, + rest_remainder=body.rest_remainder, combo=combo, venue=venue, account=body.account, book=body.book, + trader=body.trader, tags=body.tags)) + return rfq_view(rfq, {"book": body.book, "trader": body.trader}) + + @router.get("/rfqs", tags=["rfq"], summary="Our RFQs", responses=RFQ_RESPONSES) + async def list_rfqs( + principal: Caller, + engine: EngineDep, + venue: Annotated[str | None, Query(description="Only this venue")] = None, + status: Annotated[RfqStatus | None, Query(description="Only RFQs in this state")] = None, + market_id: Annotated[str | None, Query(description="Only this market or combo")] = None, + source: Annotated[Literal["venue", "engine"], Query( + description="`venue` (the default) reads each venue's list; `engine` returns the open RFQs the engine " + "follows, without a venue call")] = "venue", + limit: Annotated[int | None, Query(ge=1, le=500)] = None, + ) -> PageResponse[RfqView]: + rows: list[RfqView] = [] + for name in venues_for(engine, principal, venue, "rfq_taker"): + held = {k.split(":", 1)[1]: v for k, v in engine.rfq.rfqs.items() if k.startswith(f"{name}:")} + if source == "engine": + found = [Rfq.model_validate(h["rfq"]) for h in held.values()] + found = [r for r in found if (status is None or r.status == status) + and (market_id is None or r.instrument_id == market_id)] + else: + found = list(await tagged(name, engine.adapters[name].fetch_rfqs( + status=status, market_id=market_id, limit=limit))) + rows.extend(rfq_view(r, held.get(r.id)) for r in found) + rows = rows[:limit] if limit else rows + return PageResponse[RfqView](data=rows, next_cursor=None, count=len(rows)) + + @router.get("/rfqs/incoming", tags=["rfq"], summary="Others' open RFQs, for us to quote", + responses=RFQ_RESPONSES) + async def incoming_rfqs( + principal: Caller, + engine: EngineDep, + venue: Annotated[str | None, Query(description="Only this venue")] = None, + market_id: Annotated[str | None, Query(description="Only this market or combo")] = None, + source: Annotated[Literal["venue", "engine"], Query( + description="`venue` (the default) reads each venue; `engine` returns what the engine's incoming feed " + "last saw (the live `rfq.incoming` events), without a venue call")] = "venue", + limit: Annotated[int | None, Query(ge=1, le=500)] = None, + ) -> PageResponse[Rfq]: + rows: list[Rfq] = [] + for name in venues_for(engine, principal, venue, "rfq_quoter"): + if source == "engine": + rows.extend(r for k, r in engine.rfq.incoming.items() if k.startswith(f"{name}:") + and r.status == RfqStatus.OPEN and (market_id is None or r.instrument_id == market_id)) + else: + rows.extend(await tagged(name, engine.adapters[name].fetch_incoming_rfqs(market_id=market_id, + limit=limit))) + rows = rows[:limit] if limit else rows + return PageResponse[Rfq](data=rows, next_cursor=None, count=len(rows)) + + @router.get("/rfqs/{rfq_id}", tags=["rfq"], summary="One RFQ", responses=RFQ_RESPONSES) + async def get_rfq(principal: Caller, engine: EngineDep, rfq_id: Annotated[str, Path()], + venue: Annotated[str | None, Query()] = None) -> RfqView: + name = rfq_venue(engine, venue, rfq_id=rfq_id, capability="rfq_taker") + require_view(principal, engine, name) + rfq = await tagged(name, engine.adapters[name].fetch_rfq(rfq_id)) + return rfq_view(rfq, engine.rfq.rfqs.get(f"{name}:{rfq_id}")) + + @router.delete("/rfqs/{rfq_id}", tags=["rfq"], summary="Cancel one of our RFQs", responses=RFQ_RESPONSES) + async def cancel_rfq(principal: Caller, engine: EngineDep, rfq_id: Annotated[str, Path()], + venue: Annotated[str | None, Query()] = None) -> Canceled: + """Its open quotes end `canceled` (`quote.canceled`, `rfq.closed`). + Journaled; never refused by risk, so it works while halted.""" + name = rfq_venue(engine, venue, rfq_id=rfq_id, capability="rfq_taker") + require(principal, "trade", account_key(None, engine, name)) + await tagged(name, engine.cancel_rfq(rfq_id, venue=name)) + return Canceled(id=rfq_id, venue=name, kind="rfq") + + @router.get("/rfqs/{rfq_id}/quotes", tags=["rfq"], summary="Quotes on one of our RFQs", responses=RFQ_RESPONSES) + async def rfq_quotes(principal: Caller, engine: EngineDep, rfq_id: Annotated[str, Path()], + venue: Annotated[str | None, Query()] = None, + status: Annotated[QuoteStatus | None, Query()] = None) -> PageResponse[RfqQuote]: + name = rfq_venue(engine, venue, rfq_id=rfq_id, capability="rfq_taker") + require_view(principal, engine, name) + rows = await tagged(name, engine.adapters[name].fetch_quotes(rfq_id, status=status)) + return PageResponse[RfqQuote](data=rows, next_cursor=None, count=len(rows)) + + @router.post("/quotes/{quote_id}/accept", tags=["rfq"], summary="Take one side of a quote", + responses=ACCEPT_RESPONSES, status_code=202) + async def accept_quote(principal: Caller, engine: EngineDep, response: Response, + quote_id: Annotated[str, Path()], body: AcceptBody) -> QuoteResult: + """An order: the quote is re-read, its price checked against + `worst_price` (`409`, rule `worst_price`), an `amount` sized into + contracts, and every order risk rule run before the venue is called. + A retry under the same `client_order_id` never sends twice.""" + name = rfq_venue(engine, body.venue, account=body.account, rfq_id=body.rfq_id, quote_id=quote_id, + capability="rfq_taker") + require(principal, "trade", account_key(body.account, engine, name)) + accept = AcceptQuote.model_validate({**body.model_dump(exclude={"venue", "wait", "timeout_s"}), + "quote_id": quote_id}) + quote = await tagged(name, engine.accept_quote(accept, venue=name, wait=body.wait, timeout_s=body.timeout_s)) + result = await quote_result(engine, quote, accept.client_order_id or f"rfq-accept:{name}:{quote_id}", name) + if body.wait and result.final: + response.status_code = 200 + return result + + # -- quoter --------------------------------------------------------------- + + @router.get("/quotes", tags=["rfq"], summary="Our quotes (the quoter side)", responses=RFQ_RESPONSES) + async def my_quotes( + principal: Caller, + engine: EngineDep, + venue: Annotated[str | None, Query(description="Only this venue")] = None, + rfq_id: Annotated[str | None, Query(description="Only quotes on this RFQ")] = None, + status: Annotated[QuoteStatus | None, Query()] = None, + ) -> PageResponse[RfqQuote]: + """As each venue lists them, with the expiry the engine holds (the + venue may not take one) filled in.""" + rows: list[RfqQuote] = [] + for name in venues_for(engine, principal, venue, "rfq_quoter"): + for quote in await tagged(name, engine.adapters[name].fetch_my_quotes(rfq_id=rfq_id, status=status)): + held = engine.rfq.maker_quotes.get(f"{name}:{quote.id}") + if held and held.get("expires_at") and quote.expires_at is None: + quote = quote.model_copy(update={"expires_at": held["expires_at"]}) + rows.append(quote) + return PageResponse[RfqQuote](data=rows, next_cursor=None, count=len(rows)) + + @router.post("/quotes", tags=["rfq"], summary="Quote an incoming RFQ", responses=RFQ_RESPONSES, status_code=201) + async def send_quote(principal: Caller, engine: EngineDep, body: MakerQuoteBody) -> RfqQuote: + """Journaled; refused while halted, on a paused book, a restricted + market or over the order size limits. `expires_at` is held by the + engine, which cancels the quote when it passes.""" + name = rfq_venue(engine, body.venue, account=body.account, rfq_id=body.rfq_id, capability="rfq_quoter") + require(principal, "trade", account_key(body.account, engine, name)) + quote = MakerQuote.model_validate(body.model_dump(exclude={"venue"})) + return await tagged(name, engine.send_quote(quote, venue=name)) + + @router.put("/quotes/{quote_id}", tags=["rfq"], summary="Replace one of our quotes", responses=RFQ_RESPONSES) + async def replace_quote(principal: Caller, engine: EngineDep, quote_id: Annotated[str, Path()], + body: MakerQuoteBody) -> RfqQuote: + """The new quote may carry a new id (`quote.replaced` names both).""" + name = rfq_venue(engine, body.venue, account=body.account, quote_id=quote_id, rfq_id=body.rfq_id, + capability="rfq_quoter") + require(principal, "trade", account_key(body.account, engine, name)) + quote = MakerQuote.model_validate(body.model_dump(exclude={"venue"})) + return await tagged(name, engine.replace_quote(quote_id, quote, venue=name)) + + @router.delete("/quotes/{quote_id}", tags=["rfq"], summary="Withdraw one of our quotes", responses=RFQ_RESPONSES) + async def cancel_quote(principal: Caller, engine: EngineDep, quote_id: Annotated[str, Path()], + rfq_id: Annotated[str | None, Query()] = None, + venue: Annotated[str | None, Query()] = None) -> Canceled: + """Also declines it during the last look. Never refused by risk.""" + name = rfq_venue(engine, venue, quote_id=quote_id, rfq_id=rfq_id, capability="rfq_quoter") + require(principal, "trade", account_key(None, engine, name)) + await tagged(name, engine.cancel_quote(quote_id, rfq_id=rfq_id, venue=name)) + return Canceled(id=quote_id, venue=name, kind="quote") + + @router.post("/quotes/{quote_id}/confirm", tags=["rfq"], summary="Confirm an accepted quote of ours", + responses=ACCEPT_RESPONSES, status_code=202) + async def confirm_quote(principal: Caller, engine: EngineDep, response: Response, + quote_id: Annotated[str, Path()], body: ConfirmBody | None = None) -> QuoteResult: + """The maker's last look. Commits capital, so every order risk rule + runs on the side and size that would trade; refused while halted.""" + body = body or ConfirmBody() + name = rfq_venue(engine, body.venue, quote_id=quote_id, rfq_id=body.rfq_id, capability="rfq_quoter") + require(principal, "trade", account_key(None, engine, name)) + quote = await tagged(name, engine.confirm_quote(quote_id, rfq_id=body.rfq_id, venue=name, wait=body.wait, + timeout_s=body.timeout_s)) + result = await quote_result(engine, quote, f"rfq-confirm:{name}:{quote_id}", name) + if body.wait and result.final: + response.status_code = 200 + return result + + # -- combos --------------------------------------------------------------- + + @router.post("/combos", tags=["rfq"], summary="Create or look up the combo for these legs", + responses=RFQ_RESPONSES) + async def create_combo(principal: Caller, engine: EngineDep, body: CreateComboBody) -> Combo: + """Idempotent: the same legs give the same combo (an existing one + costs no venue quota). A signed write, so `trade`. The legs are kept + by the engine (`combo.saved`) and shown on the combo's position. + On Kalshi, `collection_id` names the collection to build it in.""" + name = rfq_venue(engine, body.venue, market_id=body.legs[0].market_id, capability="combos") + require(principal, "trade", account_key(None, engine, name)) + return await tagged(name, engine.create_combo(body.legs, venue=name, collection_id=body.collection_id)) + + @router.post("/combos/lookup", tags=["rfq"], summary="The combo for these legs, if it exists", + responses=RFQ_RESPONSES) + async def lookup_combo(principal: Caller, engine: EngineDep, body: ComboBody) -> Combo | None: + """A read: `null` when the venue has no such combo. `501` where the + venue has no read-only lookup (Polymarket US: use `POST /combos`).""" + name = rfq_venue(engine, body.venue, market_id=body.legs[0].market_id, capability="combos") + require_view(principal, engine, name) + return await tagged(name, engine.adapters[name].lookup_combo(body.legs)) + + @router.get("/combos/collections", tags=["rfq"], summary="Where combos can be built (Kalshi collections)", + responses=RFQ_RESPONSES) + async def combo_collections( + principal: Caller, + engine: EngineDep, + venue: Annotated[str | None, Query(description="The venue; defaults to the only one with collections")] = None, + status: Annotated[Literal["unopened", "open", "closed"] | None, Query()] = "open", + event_id: Annotated[str | None, Query(description="Only collections that take this event")] = None, + limit: Annotated[int | None, Query(ge=1, le=200)] = None, + cursor: Annotated[str | None, Query()] = None, + ) -> PageResponse[dict[str, Any]]: + """The venue's own collection objects (Kalshi's multivariate event + collections: `collection_ticker`, `associated_event_tickers` (always + present: every event a leg may come from), `associated_events`, + `size_min`, `size_max`...). `501` on a venue without collections. + Served from memory for 15 s.""" + if venue is None: + with_collections = [v for v, a in engine.adapters.items() if hasattr(a, "fetch_combo_collections")] + venue = with_collections[0] if len(with_collections) == 1 else None + name = rfq_venue(engine, venue, capability="combos") + require_view(principal, engine, name) + fetch = getattr(engine.adapters[name], "fetch_combo_collections", None) + if fetch is None: + from ..errors import NotSupported + raise NotSupported(f"{name}: combo collections") + page = await combo_lists.get( + ("collections", name, status, event_id, limit, cursor), + lambda: tagged(name, fetch(status=status, event_id=event_id, limit=limit, cursor=cursor))) + rows = list(page) + return PageResponse[dict[str, Any]](data=rows, next_cursor=page.next_cursor, count=len(rows)) + + @router.get("/combos/markets", tags=["rfq"], summary="Markets a combo may take as legs (Polymarket, Polymarket US)", + responses=RFQ_RESPONSES) + async def combo_markets( + principal: Caller, + engine: EngineDep, + venue: Annotated[str | None, Query(description="`polymarket` or `polymarket_us`; defaults to the only " + "venue with such a list")] = None, + event_id: Annotated[str | None, Query(description="Polymarket US: only this event's markets " + "(`polymarket_us:`). Polymarket: `400`")] = None, + tag: Annotated[str | None, Query(description="Only this tag: Polymarket US's tag slug (`nfl`, `soccer`); " + "on Polymarket a catalogue tag, applied to each page")] = None, + limit: Annotated[int | None, Query(ge=1, le=500, description="Polymarket: markets per page. Polymarket " + "US: events read per page (default 50)")] = None, + cursor: Annotated[str | None, Query()] = None, + ) -> PageResponse[ComboMarket]: + """The venue's own list of combo-eligible markets, as `ComboMarket` + rows: `market_id` (the leg's), `title`, `event_id`/`event_title` + where listed, `position_ids` (Polymarket: per outcome, the leg's + `info.position_id`), `min_legs`/`max_legs`, the venue's `status` and + `tags`, and the venue's row in `info`. A page can be short or empty + with a `next_cursor`: the venues filter after paging. Served from + memory for 15 s, so it can be polled. `501` on a venue without such + a list (Kalshi: `GET /combos/collections`).""" + if venue is None: + listing = [v for v, a in engine.adapters.items() if lists_combo_markets(a)] + venue = listing[0] if len(listing) == 1 else None + name = rfq_venue(engine, venue, capability="combos") + require_view(principal, engine, name) + fetch = getattr(engine.adapters[name], "fetch_combo_eligible_markets", None) + if fetch is None: + from ..errors import NotSupported + raise NotSupported(f"{name}: combo-eligible markets") + page = await combo_lists.get( + ("markets", name, event_id, tag, limit, cursor), + lambda: tagged(name, fetch(event_id=event_id, tag=tag, limit=limit, cursor=cursor))) + rows = list(page) + return PageResponse[ComboMarket](data=rows, next_cursor=page.next_cursor, count=len(rows)) + + boards = TtlCache(ttl_s=COMBO_BOARD_TTL_S, size=16) + + async def whole_board(request: Request, engine: Engine, name: str, pages: int) -> ComboBoard: + """serve's warm board (never waiting on a build: `status: building` + until the first ends); without a warmer (a bare trading app, or + `[combo_board] warm = false`), built on request and kept 30 s.""" + warmer = getattr(request.app.state, "combo_boards", None) + if warmer is not None and warmer.warms(name): + return warmer.board(name) + reader = catalogue_reader(request, name) + return await boards.get(("board", name, pages), + lambda: tagged(name, board_for(name, engine.adapters[name], reader, pages=pages))) + + @router.get("/combos/board/categories", tags=["rfq"], summary="A combo board's categories, with counts", + responses=RFQ_RESPONSES) + async def combo_board_categories( + principal: Caller, + engine: EngineDep, + request: Request, + venue: Annotated[str, Query(description="A venue with `combos`: `kalshi`, `polymarket`, `polymarket_us`")], + ) -> ComboBoardCategories: + """The rail of a combo builder: the board's categories (leagues, + kinds) with their event counts, the markets read and the events on + the board, its `status` (`building` until serve's first build of it + ends), `built_at` and `complete`. A few hundred bytes; read the + events a category at a time from `GET /combos/board?category=`.""" + name = rfq_venue(engine, venue, capability="combos") + require_view(principal, engine, name) + return categories_view(await whole_board(request, engine, name, BOARD_PAGES)) + + @router.get("/combos/board", tags=["rfq"], summary="A venue's combo-eligible markets as games and questions", + responses=RFQ_RESPONSES) + async def combo_board( + principal: Caller, + engine: EngineDep, + request: Request, + venue: Annotated[str, Query(description="A venue with `combos`: `kalshi`, `polymarket`, `polymarket_us`")], + pages: Annotated[int, Query(ge=1, le=20, description="Without serve's warmer only: pages of the venue's " + "list to read")] = BOARD_PAGES, + category: Annotated[str | None, Query(max_length=40, description="One category's events (`nfl`, " + "`politics`; `all`: every one), a page " + "at a time")] = None, + cursor: Annotated[str | None, Query(max_length=20, description="The page after the one that gave it")] = None, + limit: Annotated[int | None, Query(ge=1, le=MAX_PAGE_LIMIT, description="Events per page (default " + f"{PAGE_LIMIT})")] = None, + q: Annotated[str | None, Query(max_length=100, description="Only events whose title, category or " + "participants hold every word")] = None, + sort: Annotated[BoardSort, Query(description="`board` (start time), `volume` (largest first) or `start`, " + "within each category")] = "board", + ) -> ComboBoard: + """What a combo builder draws (`synpath/engine/combo_board.py`): + games (participants, and moneyline / spread / total picks where the + venue lists those types), YES / NO questions for everything else, + and categories (leagues, kinds) with counts. Each pick is a leg: + `market_id` and `outcome`, with the catalogue's prices. Kalshi's + board is its open collections' events (their markets read from the + server's catalogue); Polymarket's and Polymarket US's, their own + eligible lists. + + serve keeps every combo venue's board warm (built at start-up from + the venue's whole list, rebuilt each minute; docs/server.md), so + this never waits on a venue: until the first build ends it answers + `status: "building"` with no events. With `category`, `cursor`, + `limit`, `q` or `sort` it answers one page (`category`, `total`, + `next_cursor`) with the whole board's categories; without any, the + whole board, as before.""" + name = rfq_venue(engine, venue, capability="combos") + require_view(principal, engine, name) + board = await whole_board(request, engine, name, pages) + if category is None and cursor is None and limit is None and not q and sort == "board": + return board + try: + return board_page(board, category=category, cursor=cursor, limit=limit or PAGE_LIMIT, query=q, sort=sort) + except BadRequest as exc: + raise HTTPException(status_code=400, detail=str(exc)) from None + + @router.get("/combos/{market_id}", tags=["rfq"], summary="One combo and its legs", responses=RFQ_RESPONSES) + async def get_combo(principal: Caller, engine: EngineDep, market_id: Annotated[str, Path()], + venue: Annotated[str | None, Query()] = None) -> Combo: + """From the engine's saved legs, else the venue.""" + name = rfq_venue(engine, venue, market_id=market_id, capability="combos") + require_view(principal, engine, name) + combo = await engine.rfq.combo(market_id) + if combo is not None: + return combo + return await tagged(name, engine.adapters[name].fetch_combo(market_id)) + + add_rfq_group_routes(router) + + +# --------------------------------------------------------------------------- +# Cross-venue RFQ groups (docs/rfq-combo.md, "Cross-venue RFQ") +# --------------------------------------------------------------------------- + +from ..engine.combo_board import ( # noqa: E402 + BOARD_PAGES, MAX_PAGE_LIMIT, PAGE_LIMIT, BoardSort, ComboBoard, ComboBoardCategories, board_for, board_page, + categories_view, +) +from ..errors import BadRequest # noqa: E402 +from ..engine.rfq_group import GroupPreview, RfqGroup # noqa: E402 + + +def catalogue_reader(request: Request, venue: str) -> Any | None: + """The server's read adapter for a venue (serve's market-data registry), + or None where this app has none (a bare `create_trading_app`).""" + registry = getattr(request.app.state, "registry", None) + if registry is None: + return None + try: + return registry.get(venue) + except Exception: + return None + + +async def preview_prices(request: Request, preview: GroupPreview) -> None: + """Each offered venue's leg prices from the catalogue (YES ask for a YES + leg, 1 - YES bid for a NO leg) and their product, best effort.""" + import asyncio + + for venue in preview.offered: + if not venue.legs: + continue + reader = catalogue_reader(request, venue.venue) + found: dict[str, Any] = {} + if reader is not None: + try: + found = {m.id: m for m in await asyncio.to_thread(reader.fetch_markets_by_ids, + [leg.market_id for leg in venue.legs])} + except Exception: + found = {} + prices: list[Decimal | None] = [] + for leg in venue.legs: + market = found.get(leg.market_id) + quote = market.yes.quote if market is not None else None + raw = (quote.ask if leg.outcome == "yes" else (1 - quote.bid if quote.bid is not None else None)) \ + if quote is not None else None + price = Decimal(str(raw)).quantize(Decimal("0.0001")) if raw is not None and 0 < raw < 1 else None + prices.append(price) + venue.leg_prices = prices + if prices and all(p is not None for p in prices): + combined = Decimal(1) + for price in prices: + combined *= price # type: ignore[operator] + venue.combined_price = combined.quantize(Decimal("0.000001")) + + +class RfqGroupBody(BaseModel): + """One RFQ on every venue that lists the instrument, ranked as one.""" + + instrument_id: str | None = Field( + default=None, description="A market (`venue:native`), a bucket (`bucket:`), or a combo's own market id " + "the engine holds the legs of. Exactly one of this and `legs`") + legs: list[ComboLeg] | None = Field( + default=None, min_length=2, description="A combo's legs: each a market on any venue (or a bucket) and the " + "outcome it needs; mapped onto each combo venue by matching") + quantity: Decimal | None = Field(default=None, gt=0, description="Contracts. Exactly one of this and `amount`") + amount: Decimal | None = Field(default=None, gt=0, description="Money: quotes rank by the contracts it buys " + "after each venue's taker fee") + outcome: Outcome = Field(default="yes", description="The outcome the requester trades, in the instrument's terms") + action: Action = "buy" + cross_venue: bool = Field(default=False, description="A plain market: also ask every other RFQ venue's " + "matched market. Buckets and combos always fan out") + venues: list[str] | None = Field(default=None, description="Only these venues") + exclude_venues: list[str] = Field(default_factory=list, description="Not these venues") + book: str | None = None + trader: str | None = None + tags: dict[str, str] = Field(default_factory=dict) + + @model_validator(mode="after") + def _consistent(self) -> "RfqGroupBody": + if (self.quantity is None) == (self.amount is None): + raise ValueError("size an RFQ group by quantity (contracts) or amount (money), exactly one") + if (self.instrument_id is None) == (self.legs is None): + raise ValueError("give instrument_id or legs, exactly one") + return self + + +class RfqGroupPreviewBody(BaseModel): + """`POST /rfq-groups` without creating anything: the size is optional.""" + + instrument_id: str | None = Field(default=None, description="A market, a bucket or a combo's own id. Exactly " + "one of this and `legs`") + legs: list[ComboLeg] | None = Field(default=None, min_length=2) + quantity: Decimal | None = Field(default=None, gt=0) + amount: Decimal | None = Field(default=None, gt=0, description="Unsized, a buy by amount is planned") + outcome: Outcome = "yes" + action: Action = "buy" + cross_venue: bool = False + venues: list[str] | None = None + exclude_venues: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def _consistent(self) -> "RfqGroupPreviewBody": + if self.quantity is not None and self.amount is not None: + raise ValueError("size a preview by quantity or amount, at most one") + if (self.instrument_id is None) == (self.legs is None): + raise ValueError("give instrument_id or legs, exactly one") + return self + + +class GroupAcceptBody(BaseModel): + quote_id: str = Field(description="A quote from the group's ranked list") + venue: str | None = Field(default=None, description="The quote's venue. Required when the group spans more " + "than one venue: quote ids are the venues' own, so two " + "venues may return the same one (`400 venue_required`)") + worst_price: Decimal | None = Field(default=None, gt=0, lt=1, description="The worst YES price accepted, in the " + "group's terms (a flipped member's is " + "turned over)") + wait: bool = Field(default=False, description="Follow the accept until the quote is final, up to `timeout_s`") + timeout_s: float = Field(default=30.0, gt=0, le=120) + tags: dict[str, str] = Field(default_factory=dict) + + +class GroupAcceptResult(BaseModel): + """What an accept on a group did, as far as it is known yet.""" + + group: RfqGroup + quote: RfqQuote = Field(description="The member quote, as its venue answered") + venue: str + client_order_id: str = Field(description="The member accept's intent key") + claim_key: str = Field(description="The group's claim intent: one live claim per group") + final: bool + intent_state: str | None = None + order_id: str | None = None + + +GROUP_ACCEPT_RESPONSES: dict[int | str, dict[str, Any]] = { + **RFQ_RESPONSES, + 200: {"model": GroupAcceptResult, "description": "`wait` was true and the quote is final"}, + 202: {"description": "Claimed, the other venues cancelled, the accept sent; the rest arrives as `rfq_group.*` " + "and `quote.*` events. Also `submission_in_doubt` when the venue gave no answer"}, + 409: {"model": ErrorBody, "description": "A risk rule or the halt (`details.rule`), or the group already has an " + "accept in flight (`rfq_group_accept_in_flight`) or done " + "(`rfq_group_accepted`)"}, +} + + +def group_accounts(engine: Engine, group: RfqGroup) -> list[str]: + return [account_key(None, engine, venue) for venue in sorted(group.venues())] + + +def require_group(principal: Principal, engine: Engine, group: RfqGroup, permission: str) -> None: + """A group is seen by a key that may see every venue it asks, and changed + (accepted, cancelled) by one that may trade every one: an accept cancels + the others.""" + for account in group_accounts(engine, group): + if permission == "view": + if not visible(principal, account): + raise HTTPException(status_code=403, detail=f"this key may not view {account}") + else: + require(principal, "trade", account) + + +def add_rfq_group_routes(router: APIRouter) -> None: + @router.post("/rfq-groups", tags=["rfq"], summary="Ask every venue that lists it", responses=RFQ_RESPONSES, + status_code=201) + async def create_rfq_group(principal: Caller, engine: EngineDep, body: RfqGroupBody) -> RfqGroup: + """One RFQ per venue: a bucket's members, a combo's legs mapped onto + each combo venue, or a market and (with `cross_venue`) its matches. + Venues this key may not trade are skipped (`forbidden`), like those + missing a leg or refusing one, each with its reason in `skipped`. + `400 no_venue` (with `details.skipped`) when none is left. Quotes + arrive as `rfq_group.quote` and `rfq_group.updated` events.""" + capable = [v for v, a in engine.adapters.items() if a.has.get("rfq_taker")] + for name in [*(body.venues or []), *body.exclude_venues]: + if name not in engine.adapters: + raise http_error(404, "unknown_venue", f"this server does not trade {name}", venue=name) + refused = {v: f"this key may not trade {account_key(None, engine, v)}" for v in capable + if not principal.may("trade", account_key(None, engine, v))} + for name in body.venues or []: + if name in refused: + raise HTTPException(status_code=403, detail=refused[name]) + if capable and len(refused) == len(capable): + raise HTTPException(status_code=403, detail="this key may not trade on any RFQ venue") + from ..errors import BadRequest + try: + return await engine.create_rfq_group( + body.instrument_id, legs=body.legs, quantity=body.quantity, amount=body.amount, + outcome=body.outcome, action=body.action, cross_venue=body.cross_venue, venues=body.venues, + exclude_venues=body.exclude_venues, refused_venues=refused, book=body.book, trader=body.trader, + tags=body.tags) + except BadRequest as exc: + skipped = getattr(exc, "skipped", None) + if skipped is not None: + raise http_error(400, "no_venue", str(exc), skipped=skipped) from None + raise + + previews = TtlCache(ttl_s=PREVIEW_TTL_S, size=128) + + @router.post("/rfq-groups/preview", tags=["rfq"], summary="Which venues a group would ask, without creating it", + responses=RFQ_RESPONSES) + async def preview_rfq_group(principal: Caller, engine: EngineDep, request: Request, + body: RfqGroupPreviewBody) -> GroupPreview: + """A dry run of `POST /rfq-groups`: each venue's mapped market or + legs (matching, eligibility, Kalshi's collection) in `offered`, with + the legs' catalogue prices and their product (an estimate, not a + quote), or the reason it would be skipped. Nothing is created, + journaled or sent to a venue's RFQ desk; the matching service and + the venues' eligibility lists are read. Served from memory for 10 s.""" + capable = [v for v, a in engine.adapters.items() if a.has.get("rfq_taker")] + for name in [*(body.venues or []), *body.exclude_venues]: + if name not in engine.adapters: + raise http_error(404, "unknown_venue", f"this server does not trade {name}", venue=name) + refused = {v: f"this key may not trade {account_key(None, engine, v)}" for v in capable + if not principal.may("trade", account_key(None, engine, v))} + + async def read() -> GroupPreview: + preview = await engine.preview_rfq_group( + body.instrument_id, legs=body.legs, quantity=body.quantity, amount=body.amount, + outcome=body.outcome, action=body.action, cross_venue=body.cross_venue, venues=body.venues, + exclude_venues=body.exclude_venues, refused_venues=refused) + await preview_prices(request, preview) + return preview + + key = ("preview", body.model_dump_json(), tuple(sorted(refused))) + return (await previews.get(key, read)).model_copy(deep=True) + + @router.get("/rfq-groups", tags=["rfq"], summary="Cross-venue RFQ groups", responses=RFQ_RESPONSES) + async def list_rfq_groups( + principal: Caller, + engine: EngineDep, + status: Annotated[str | None, Query(description="Only groups in this state")] = None, + limit: Annotated[int | None, Query(ge=1, le=500)] = None, + ) -> PageResponse[RfqGroup]: + """The groups the engine holds (open ones, and those finished since + it started), newest first, that this key may see every venue of.""" + rows = [g for g in engine.rfq_groups.list(status=status) + if all(visible(principal, a) for a in group_accounts(engine, g))] + rows = rows[:limit] if limit else rows + return PageResponse[RfqGroup](data=rows, next_cursor=None, count=len(rows)) + + @router.get("/rfq-groups/{group_id}", tags=["rfq"], summary="One group: members, skipped venues, ranked quotes", + responses=RFQ_RESPONSES) + async def get_rfq_group(principal: Caller, engine: EngineDep, group_id: Annotated[str, Path()], + refresh: Annotated[bool, Query(description="Read every open member's quotes from its " + "venue first")] = False) -> RfqGroup: + group = await engine.rfq_group(group_id) + require_group(principal, engine, group, "view") + if refresh and not group.final: + group = await engine.rfq_groups.refresh(group_id) + return group + + @router.post("/rfq-groups/{group_id}/accept", tags=["rfq"], summary="Take one quote; cancel the other venues", + responses=GROUP_ACCEPT_RESPONSES, status_code=202) + async def accept_rfq_group(principal: Caller, engine: EngineDep, response: Response, + group_id: Annotated[str, Path()], body: GroupAcceptBody) -> GroupAcceptResult: + """Checked like any accept (risk, `worst_price`) before anything is + written; then the group is claimed in the journal, every other + member RFQ is cancelled (Polymarket global's are left to expire), and + the quote is accepted. One accept per group: a second is `409`. The + quote is named by `{quote_id, venue}`; `venue` may be left out only + on a one-venue group (`400 venue_required`).""" + group = await engine.rfq_group(group_id) + require_group(principal, engine, group, "trade") + from ..errors import BadRequest + try: + done = await engine.accept_group_quote(group_id, body.quote_id, venue=body.venue, + worst_price=body.worst_price, wait=body.wait, + timeout_s=body.timeout_s, tags=body.tags) + except BadRequest as exc: + reason = getattr(exc, "reason", None) + if reason in ("venue_required", "venue_not_in_group"): + raise http_error(400, reason, str(exc), venues=sorted(group.venues())) from None + raise + intent = await engine.journal.intent(done.client_order_id) + result = GroupAcceptResult(group=done.group, quote=done.quote, venue=done.venue, + client_order_id=done.client_order_id, claim_key=done.claim_key, + final=done.quote.is_terminal, intent_state=intent.state if intent else None, + order_id=(intent.order_id if intent else None) or None) + if body.wait and result.final: + response.status_code = 200 + return result + + @router.delete("/rfq-groups/{group_id}", tags=["rfq"], summary="Cancel every venue's RFQ in a group", + responses=RFQ_RESPONSES) + async def cancel_rfq_group(principal: Caller, engine: EngineDep, group_id: Annotated[str, Path()]) -> RfqGroup: + """Never refused by risk, so it works while halted. `409` while an + accept is in flight (the others are cancelled already).""" + group = await engine.rfq_group(group_id) + require_group(principal, engine, group, "trade") + return await engine.cancel_rfq_group(group_id) diff --git a/synpath/server/serve.py b/synpath/server/serve.py index 6743665..aecc6bc 100644 --- a/synpath/server/serve.py +++ b/synpath/server/serve.py @@ -13,14 +13,23 @@ streams, so stops see prices and parents hear their children fill; * **reconciliation**, the **end-of-day** close, **halt/resume** requests written by `synpath halt`, and a periodic status line; -* the **HTTP API**: the read app at `/` and the trading app at `/trading`, - with `/trading/ws/events`. +* the **combo boards** (`synpath.server.combo_warmer`): every combo venue's + board built at start-up and rebuilt each minute, so `/trading/combos/board` + never waits on a venue; +* the **HTTP API**: the read app at `/` (with batch reads and search), + matching and tick history from Synpath's hosted services at `/matching` + and `/history` (serve holds the Synpath API key), live market data at + `/ws/market` (sharing the feeds' venue connections, and streaming a + bucket's merged book), and the trading app + at `/trading`, with `/trading/ws/events`. Every route except `/`, `/health` + and the API docs requires an access token. With no configuration it trades every venue whose credentials it finds and still serves market data if it finds none. The first start on a new control database creates an owner access token; that token makes every other. -`SIGINT`/`SIGTERM` stop the HTTP server, apply the halt policy (cancel -everything resting, by default), close the streams, release the lease. +`SIGINT`/`SIGTERM` stop the HTTP server, preserve durable orders by +default, close the streams, and release the lease. `halt_on_exit=true` +explicitly applies the halt policy before shutdown. """ from __future__ import annotations @@ -29,7 +38,7 @@ import logging import sys from dataclasses import dataclass, field -from typing import Any +from typing import Any, Callable from . import local @@ -53,10 +62,10 @@ def read_options(market_data: dict[str, Any]) -> dict[str, dict[str, Any]]: return {venue: {"api_key": key.api_key} for venue, key in market_data.items() if venue in READ_API_KEY_VENUES} +# Venues whose read adapter takes an API key: predict.fun's mainnet refuses +# every request without one; Opinion's raises its rate limit with one; SX Bet +# documents its book snapshots as keyed. READ_API_KEY_VENUES = ("opinion", "predict_fun", "sx_bet") -"""Venues whose read adapter takes an API key: predict.fun's mainnet refuses -every request without one; Opinion's raises its rate limit with one; SX Bet -documents its book snapshots as keyed.""" @dataclass @@ -68,12 +77,16 @@ class Stack: reconciler: Any eod: Any config: dict[str, Any] + # The owner access token created on this start; `None` if the control database + # already had users. owner_key: str | None = None - """The owner access token created on this start; `None` if the control database - already had users.""" + # The access token on record in the local registry for this server's address. local_key: str | None = None - """The access token on record in the local registry for this server's address.""" extra_tasks: dict[str, Any] = field(default_factory=dict) + # The `/ws/market` hub: venue market connections shared with the feeds. + market_streams: Any | None = None + # Every combo venue's board, kept warm (`synpath.server.combo_warmer`); None with `[combo_board] warm = false`. + combo_boards: Any | None = None async def build( @@ -88,21 +101,36 @@ async def build( host: str = "127.0.0.1", port: int = 8000, home_dir: str | None = None, + registry: Any | None = None, + market_stream_factory: Callable[[str], Any] | None = None, + hosted_http: Any | None = None, ) -> Stack: - """Assemble and start everything except the HTTP server. `adapters` and - `stream_map` let a test inject paper venues and fake streams.""" + """Assemble and start everything except the HTTP server. `adapters`, + `stream_map`, `registry` (the read app's market-data adapters), + `market_stream_factory` (a venue's market stream for `/ws/market`, where + the engine has none) and `hosted_http` (the `httpx.Client` the matching and + history routes call Synpath's hosted services through) let a test inject + paper venues, fake streams and a mocked hosted API.""" from ..engine.__main__ import build_adapters, risk_from from ..engine.engine import Engine, EngineConfig from ..engine.eod import EndOfDay - from ..engine.feeds import Feeds, default_streams + from ..engine.feeds import Feeds, default_streams, market_stream from ..engine.reconcile import Reconciler from ..trading.credentials import load_credentials, load_market_data + from fastapi import Depends + from .api import VenueRegistry, create_app + from .bucket_books import BucketBooks + from .hosted_routes import add_hosted_routes + from .market_stream import MarketStreams, add_market_socket from .store import ControlStore - from .trading import create_trading_app + from .trading import caller, create_trading_app config = dict(config or {}) - credentials = load_credentials(dotenv=dotenv) if adapters is None or (streams and stream_map is None) else {} + needs_credentials = adapters is None or (streams and (stream_map is None or market_stream_factory is None)) + credentials = load_credentials(dotenv=dotenv) if needs_credentials else {} + # Always loaded, injected adapters or not: the API keys alone open the + # market streams and the read app's keyed reads. market_data = load_market_data(dotenv=dotenv) if adapters is None: config = venues_config(config, credentials) @@ -112,6 +140,10 @@ async def build( "Run `synpath doctor` to see what is missing.") engine_config = EngineConfig( journal_path=config.get("journal", journal), + storage_backend=config.get("storage_backend", "sqlite"), + storage_schema=config.get("storage_schema", "public"), + session_timezone=config.get("session_timezone", "Europe/London"), + session_end=config.get("session_end", "23:59:59"), poll_interval_s=float(config.get("poll_interval_s", 5)), reconcile_interval_s=float(config.get("reconcile_interval_s", 60)), sweep_interval_s=float(config.get("sweep_interval_s", 10)), @@ -120,8 +152,14 @@ async def build( ) engine = Engine(adapters, engine_config, risk=risk_from(config)) feeds = None - if streams and config.get("streams", True): - feeds = Feeds(engine, stream_map if stream_map is not None else default_streams(adapters, credentials, market_data)) + streaming = streams and config.get("streams", True) + if streaming: + feeds = Feeds(engine, stream_map if stream_map is not None + else default_streams(adapters, credentials, market_data)) + market_streams = MarketStreams( + market_stream_factory or (lambda venue: market_stream(venue, credentials.get(venue) or market_data.get(venue))), + feeds=feeds, enabled=bool(streaming), book_interval_s=float(config.get("book_interval_s", 0.1)), + ) reconciler = Reconciler(engine, orphan_policy=config.get("orphan_policy", "report")) eod = EndOfDay(engine, hour_utc=int(config.get("eod_hour_utc", 0))) @@ -133,15 +171,55 @@ async def build( owner_key = issued.secret # Leave the token where clients on this machine find it (synpath.server.local). stack_key = local.remember(host, port, control=config.get("control", control), - journal=engine_config.journal_path, key=owner_key, home_dir=home_dir) + journal=engine.journal.path, key=owner_key, home_dir=home_dir) if feeds is not None: await feeds.start() - app = create_app(registry=VenueRegistry(options=read_options(market_data))) - app.mount("/trading", create_trading_app(engine, store)) + # The market-data routes take the same access token as /trading: the + # read app's dependency hook gets `caller`, which finds the control store + # on this (root) app. Its adapters get serve's own market-data API keys. + if registry is None: + registry = VenueRegistry(options=read_options(market_data)) + app = create_app(registry=registry, dependencies=[Depends(caller)]) + app.state.control = store + # Matching and tick history: serve calls Synpath's hosted services with + # its own Synpath API key, so its clients need none. + add_hosted_routes(app, [Depends(caller)]) + app.state.hosted_http = hosted_http + add_market_socket(app, market_streams) + # Buckets: the merged book on /ws/market and the router preview on + # /trading share one fee cache and read members' books from the hub. + bucket_books = BucketBooks(engine, app.state.registry, market_streams) + market_streams.buckets = bucket_books + trading_app = create_trading_app(engine, store) + trading_app.state.market_streams = market_streams + trading_app.state.bucket_books = bucket_books + # The combo board and group previews read leg markets and quotes from the + # same catalogue as the read app. + trading_app.state.registry = app.state.registry + combo_boards = combo_board_warmer(engine, app.state.registry, config) + if combo_boards is not None: + # The board routes answer from it, never waiting on a build; the RFQ + # groups name the legs a venue lacks by its boards' picks. + trading_app.state.combo_boards = combo_boards + engine.rfq_groups.labeler = combo_boards.labels_for + app.mount("/trading", trading_app) app.state.trading_path = "/trading" return Stack(engine=engine, feeds=feeds, store=store, app=app, reconciler=reconciler, eod=eod, - config=config, owner_key=owner_key, local_key=stack_key) + config=config, owner_key=owner_key, local_key=stack_key, market_streams=market_streams, + combo_boards=combo_boards) + + +def combo_board_warmer(engine: Any, registry: Any, config: dict[str, Any]) -> Any | None: + """The combo board warmer `[combo_board]` configures (on by default).""" + from .combo_warmer import PAUSE_S, REFRESH_INTERVAL_S, ComboBoardWarmer + + section = dict(config.get("combo_board") or {}) + if not section.get("warm", True): + return None + return ComboBoardWarmer(engine, registry, + refresh_interval_s=float(section.get("refresh_interval_s", REFRESH_INTERVAL_S)), + pause_s=float(section.get("pause_s", PAUSE_S))) def background(stack: Stack) -> dict[str, Any]: @@ -156,16 +234,22 @@ def background(stack: Stack) -> dict[str, Any]: loops["control"] = _control_loop(engine, asyncio.Event()) loops["eod"] = stack.eod.loop() loops["status"] = _status_loop(engine, float(stack.config.get("status_interval_s", 60))) + if stack.combo_boards is not None and stack.combo_boards.venues(): + loops["combo_boards"] = stack.combo_boards.loop() return loops async def shutdown(stack: Stack) -> None: engine = stack.engine - if stack.config.get("halt_on_exit", True) and engine.running: + if stack.config.get("halt_on_exit", False) and engine.running: try: await engine.halt("the server is shutting down", policy=engine.config.halt_policy) except Exception: log.exception("synpath serve: halting on exit failed") + if stack.combo_boards is not None: + await stack.combo_boards.close() + if stack.market_streams is not None: + await stack.market_streams.close() if stack.feeds is not None: await stack.feeds.close() await engine.stop() @@ -202,10 +286,10 @@ async def serve(args: argparse.Namespace) -> int: elif stack.owner_key: print("\n A new control database: this is the owner access token. It is shown once.\n" f" {stack.owner_key}\n" - " Send it as `Authorization: Bearer ` to /trading; it can issue every other token.\n") + " Send it as `Authorization: Bearer ` on every route; it can issue every other token.\n") if args.host not in ("127.0.0.1", "localhost", "::1"): - print(f"warning: binding to {args.host}. The market-data routes at / have no authentication; " - "/trading requires an access token. Put a proxy with TLS in front of anything public.") + print(f"warning: binding to {args.host}. Every route except /, /health and the API docs requires an access " + "token, but it travels in clear text over plain HTTP. Put a proxy with TLS in front of anything public.") server = uvicorn.Server(uvicorn.Config(stack.app, host=args.host, port=args.port, log_level=args.log_level.lower())) loop = asyncio.get_running_loop() @@ -219,8 +303,8 @@ def _died(task: asyncio.Task) -> None: task.add_done_callback(_died) engine = stack.engine - print(f"synpath serving on http://{args.host}:{args.port} (trading at /trading), " - f"venues: {', '.join(sorted(engine.adapters)) or 'none'}, journal: {engine.config.journal_path}") + print(f"synpath serving on http://{args.host}:{args.port} (live market data at /ws/market, trading at /trading), " + f"venues: {', '.join(sorted(engine.adapters)) or 'none'}, journal: {engine.journal.path}") try: await server.serve() finally: @@ -234,7 +318,7 @@ def _died(task: asyncio.Task) -> None: def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser(prog="synpath serve", description="Run the engine, the venues' streams and the " - "HTTP API (market data at /, trading at /trading) in one process.") + "HTTP API (market data at / and /ws/market, trading at /trading) in one process.") p.add_argument("--host", default="127.0.0.1", help="default: 127.0.0.1") p.add_argument("--port", type=int, default=8000, help="default: 8000") p.add_argument("--config", default=None, help="TOML or JSON: venues, risk, journal. Optional") diff --git a/synpath/server/trading.py b/synpath/server/trading.py index 6763053..64abf57 100644 --- a/synpath/server/trading.py +++ b/synpath/server/trading.py @@ -1,7 +1,7 @@ """The trading surface over HTTP, and the event stream beside it. -The read app (`synpath.server.api`) has no accounts because reading a public -market needs none. This one places orders, so it needs to know who is asking +The read app (`synpath.server.api`) keeps no accounts because reading a public +market needs none (`synpath serve` still puts it behind this app's token). This one places orders, so it needs to know who is asking and what they may touch, and both are checked on every route. What shapes it: @@ -30,8 +30,12 @@ from __future__ import annotations import asyncio +import base64 import json import logging +from ..engine.execution_scope import BucketNotFound +from .order_access import may_view_order, may_trade_order, may_view_event + from decimal import Decimal from typing import Annotated, Any, Iterable, Literal @@ -45,27 +49,46 @@ ) from ..engine.engine import Engine from ..errors import ( - AuthenticationError, BadRequest, ExchangeError, NetworkError, RateLimitExceeded, RequestTimeout, SynpathError, + AuthenticationError, BadRequest, ExchangeError, NetworkError, NotSupported, RateLimitExceeded, RequestTimeout, + SynpathError, ) from ..engine.risk import RiskConfig from ..trading.errors import ( - InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, RateBudgetExceeded, RiskRejected, + CancelUnconfirmed, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, + RateBudgetExceeded, RiskRejected, SubmissionInDoubt, SubmissionPending, ) from .errors import install_error_handlers from ..trading.types import ( - Account, Balance, EditRequest, Fill, Order, OrderRequest, Position, Settlement, TimeInForce, + Account, Balance, EditRequest, Fill, Order, OrderRequest, Position, Settlement, Side, TimeInForce, ) from .models import ErrorBody, PageResponse from .store import ALL_ACCOUNTS, ControlStore, Grant, Permission, Principal log = logging.getLogger("synpath.server") +# Library exception to status code on the trading routes, most specific +# class first. A venue refusing our own credentials is 502: the caller's token +# was fine, the server's venue key was not. +# +# An order whose outcome is not known yet is 202, not a 5xx: the instruction +# is recorded and the send may have happened, so it has not failed. The body +# is the usual error envelope (`submission_in_doubt` for this send, +# `submission_pending` for a retry while the first send is still being +# reconciled), with `retryable: true` and the `client_order_id` to retry +# under; the same id never sends twice. A cancel the venue neither performed +# nor recognises is 409 `cancel_unconfirmed`: the order exists here, and its +# last observed status stands. TRADING_STATUS: dict[type[Exception], int] = { + SubmissionInDoubt: 202, + SubmissionPending: 202, + CancelUnconfirmed: 409, OrderNotFound: 404, + BucketNotFound: 404, InsufficientFunds: 400, OrderRejected: 400, InvalidOrder: 400, BadRequest: 400, + NotSupported: 501, MarketHalted: 409, RiskRejected: 409, RateBudgetExceeded: 429, @@ -75,9 +98,35 @@ AuthenticationError: 502, ExchangeError: 502, } -"""Library exception to status code on the trading routes. A venue refusing -our own credentials is 502: the caller's token was fine, the server's venue -key was not.""" + + +DEFAULT_ORDERS_PAGE = 100 +MAX_ORDERS_PAGE = 1000 + + +def orders_cursor(key: tuple[int, str, str]) -> str: + """The paging key of a page's last order, `(created_ts, venue, id)`, as an opaque token.""" + return base64.urlsafe_b64encode(json.dumps({"v": 2, "key": list(key)}).encode()).decode().rstrip("=") + + +def orders_cursor_key(cursor: str | None) -> tuple[int, str, str] | None: + if not cursor: + return None + try: + decoded = json.loads(base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))) + if not isinstance(decoded, dict) or decoded.get("v") != 2: + raise ValueError("old cursor; restart pagination") + created_ts, venue, order_id = decoded["key"] + return int(created_ts), str(venue), str(order_id) + except (ValueError, TypeError, KeyError): + raise HTTPException(status_code=400, detail="cursor is not one this server gave out") from None + + +def current_view(engine: Engine, order: Order) -> Order: + """An engine-held parent this process runs is read from memory, which is + what its journal row was last written from; anything else is the row.""" + parent = engine.managed_order_controller.get(order.id) if order.held_by.value == "engine" else None + return parent.as_order() if parent is not None else order def _tag_venue(exc: Exception, venue: str | None) -> None: @@ -145,19 +194,9 @@ class HaltResult(BaseModel): canceled: dict[str, Any] = Field(default_factory=dict) remaining: dict[str, Any] = Field(default_factory=dict) managed: int = 0 - - -class FairValueBody(BaseModel): - account: str - market_id: str - value: Decimal - source: str = "manual" - - -class FairValueView(BaseModel): - account: str - market_id: str - value: Decimal + rfq: dict[str, Any] = Field(default_factory=dict, description="With the `cancel` policy: the RFQs we asked " + "(`rfqs`) and the quotes we made (`quotes`) cancelled, per venue: a count, or " + "what failed") class PnlRow(BaseModel): @@ -179,11 +218,74 @@ class PnlView(BaseModel): class BucketBody(BaseModel): - """A bucket to create. The server assigns the id.""" + """A bucket to create. The server assigns the id unless one is given.""" book: str = Field(description="The strategy book the bucket belongs to, as on orders") name: str = Field(description="A label for people; not unique") - members: list[BucketMember] = Field(description="Two or more Synpath market ids, each with `flip`") + members: list[BucketMember] = Field(description="Two or more Synpath market ids, each with `flip` and " + "`enabled` (default true; at least one enabled)") + id: str | None = Field(default=None, description="A UUID made by the client (32 hex digits, or the dashed " + "form, kept as given in lower case), so a bucket defined " + "elsewhere keeps its id. Taken: 409") + + +class BucketUpdate(BaseModel): + """A change to a bucket; fields left out are left alone. `members` + replaces the whole list, each member's `enabled` included (switching a + member off keeps it in the definition but out of routing and the merged + book). `status` archives or restores it.""" + + name: str | None = None + book: str | None = None + members: list[BucketMember] | None = None + status: Literal["active", "archived"] | None = None + + +class BucketPlanBody(BaseModel): + """What to preview: an order on the bucket, as `POST /orders` would take it.""" + + side: Side = Field(description="`buy` takes the bucket's YES, `sell` its NO") + quantity: Decimal = Field(gt=0, description="Contracts, in bucket terms") + worst_price: Decimal = Field(gt=0, lt=1, description="The worst net price accepted, in bucket terms (an " + "order's `price`)") + + +class PlanLegView(BaseModel): + """One leg of a plan: what one member venue would receive.""" + + market_id: str + venue: str + flip: bool + side: str = Field(description="The member's own side, after `flip`") + price: Decimal = Field(description="The member's own YES price, after `flip` and tick rounding: what the " + "venue would receive") + quantity: Decimal + bucket_price: Decimal = Field(description="The worst level this leg reaches, in bucket terms, before fees") + net_price: Decimal = Field(description="Volume-weighted net price of the levels taken, in bucket terms, fee " + "floor included") + fee: Decimal = Field(description="The taker fee this leg is expected to pay, floor included") + fee_floor: Decimal = Field(description="How much of `fee` is the venue's per-order floor") + + +class BucketPlanView(BaseModel): + """`synpath.engine.router.plan` on the members' current books. Nothing was placed.""" + + bucket_id: str + side: str + quantity: Decimal + worst_price: Decimal + legs: list[PlanLegView] + allocated: Decimal = Field(description="Contracts the legs add up to; never more than `quantity`") + unfilled: Decimal + stop_reason: Literal["worst_price", "liquidity", "min_quantity"] | None = Field( + description="Why the plan stops short of `quantity`; null when it fills it") + expected_net_price: Decimal | None = Field(description="Volume-weighted across legs, in bucket terms, net of fees") + fees: Decimal = Field(description="Every leg's expected taker fee") + books: dict[str, Literal["stream", "rest", "missing"]] = Field( + description="Per member, where its book came from: serve's live stream, a venue read for this request, " + "or nowhere") + fees_unknown: list[str] = Field(description="Members whose taker fee the library cannot price; counted as free") + computed_at: int = Field(description="Milliseconds since the epoch") class EditBody(BaseModel): @@ -288,6 +390,15 @@ async def caller(request: Request, store: Annotated[ControlStore, Depends(get_st return await principal_of(request, store) +async def socket_principal(socket: WebSocket, store: ControlStore, key: str | None) -> Principal | None: + """Who an accepted WebSocket belongs to: the token from `?key=` (a browser + cannot set headers on a socket) or from `Authorization: Bearer`; `None` + without a valid one. The socket counterpart of `caller`.""" + header = socket.headers.get("authorization") or "" + secret = key or (header[7:].strip() if header.lower().startswith("bearer ") else "") + return await store.principal(secret) if secret else None + + Caller = Annotated[Principal, Depends(caller)] EngineDep = Annotated[Engine, Depends(get_engine)] StoreDep = Annotated[ControlStore, Depends(get_store)] @@ -333,6 +444,50 @@ def sees_bucket(principal: Principal, bucket: Bucket, engine: Engine) -> bool: return all(visible(principal, account) for account in bucket_accounts(bucket, engine)) +def client_bucket_id(value: str) -> str: + """A client-made bucket id: any UUID, kept as given (lower case) so the + client's references to `bucket:` stay valid.""" + import uuid + + text = value[len("bucket:"):] if is_bucket_id(value) else value + try: + uuid.UUID(text) + except ValueError: + raise HTTPException(status_code=400, detail="a bucket id is a UUID: 32 hex digits or the dashed form") from None + if len(text) not in (32, 36): + raise HTTPException(status_code=400, detail="a bucket id is a UUID: 32 hex digits or the dashed form") + return text.lower() + + +def checked_definition(principal: Principal, engine: Engine, bucket: Bucket) -> None: + """The checks every stored definition passes: its shape, a venue this + server trades for every member, and a caller allowed to trade each of + them, since an order on the bucket will route there.""" + try: + bucket.check() + except BadRequest as exc: + raise HTTPException(status_code=400, detail=str(exc)) from None + missing = sorted(bucket.venues() - set(engine.adapters)) + if missing: + raise HTTPException(status_code=400, detail=f"this server does not trade {', '.join(missing)}: " + "every member venue needs credentials here") + for member_venue in sorted(bucket.venues()): + require(principal, "trade", account_key(None, engine, member_venue)) + + +def plan_view(bucket: Bucket, body: "BucketPlanBody", result: Any) -> "BucketPlanView": + plan = result.plan + legs = [PlanLegView(market_id=leg.market_id, venue=ids.venue_of(leg.market_id), flip=leg.flip, + side=leg.side.value, price=leg.price, quantity=leg.quantity, bucket_price=leg.bucket_price, + net_price=leg.net_price, fee=leg.fee, fee_floor=leg.fee_floor) for leg in plan.legs] + return BucketPlanView( + bucket_id=bucket.id, side=body.side.value, quantity=body.quantity, worst_price=body.worst_price, legs=legs, + allocated=plan.allocated, unfilled=plan.unfilled, stop_reason=plan.reason or None, + expected_net_price=plan.expected_net_price, fees=sum((leg.fee for leg in plan.legs), Decimal("0")), + books=result.sources, fees_unknown=result.fees_unknown, computed_at=result.computed_at, + ) + + def bucket_report(parent: Any) -> BucketOrderReport: report = {k: v for k, v in parent.report().items() if k in BucketOrderReport.model_fields} return BucketOrderReport.model_validate({**report, "stop_reason": report.get("stop_reason") or None, @@ -376,25 +531,25 @@ async def accounts(principal: Caller, engine: EngineDep) -> list[AccountView]: async def status(principal: Caller, engine: EngineDep) -> EngineStatus: require(principal, "view") return EngineStatus( - owner=engine.journal.owner, running=engine.running, halted=engine.risk.kill.engaged, - halt_reason=engine.risk.kill.reason, open_orders=len(engine.open_orders()), - managed_orders=len(engine.orders.live()), positions=len(engine.ledger.open_positions()), - risk_version=engine.risk.config_version, journal_events=await engine.journal.last_seq(), + owner=engine.journal.owner, running=engine.running, halted=engine.order_risk_checks.kill.engaged, + halt_reason=engine.order_risk_checks.kill.reason, open_orders=len(engine.open_orders()), + managed_orders=len(engine.managed_order_controller.live()), positions=len(engine.ledger.open_positions()), + risk_version=engine.order_risk_checks.config_version, journal_events=await engine.journal.last_seq(), ) # -- orders --------------------------------------------------------------- - @router.post("/orders", tags=["orders"], summary="Place an order", responses=RESPONSES, status_code=201) + @router.post("/orders", tags=["orders"], summary="Place an order", responses={ + **RESPONSES, 202: {"model": ErrorBody, "description": "Outcome not known yet (`submission_in_doubt` or " + "`submission_pending`): retry with the same " + "client_order_id, which never sends twice"}}, + status_code=201) async def create_order(principal: Caller, engine: EngineDep, body: OrderRequest) -> Order: if is_bucket_id(body.market_id): - # A bucket order puts legs on every member venue: the caller must - # be allowed to trade on each of them. - row = await engine.journal.bucket(bucket_id_of(body.market_id)) - if row is None: - raise HTTPException(status_code=404, detail=f"{body.market_id}: no such bucket") - for member_venue in sorted(Bucket.model_validate(row).venues()): - require(principal, "trade", account_key(None, engine, member_venue)) - venue = None + def authorize(accounts): + for account in accounts.values(): + require(principal, "trade", account.key) + return await engine.submit(body, authorize_execution_accounts=authorize) else: venue = order_venue(body, engine) if venue is None: @@ -410,21 +565,18 @@ async def create_order(principal: Caller, engine: EngineDep, body: OrderRequest) # -- buckets -------------------------------------------------------------- - @router.post("/buckets", tags=["buckets"], summary="Create a bucket", responses=RESPONSES, status_code=201) + @router.post("/buckets", tags=["buckets"], summary="Create a bucket", responses={ + **RESPONSES, 409: {"model": ErrorBody, "description": "A bucket with that id already exists"}}, + status_code=201) async def create_bucket(principal: Caller, engine: EngineDep, body: BucketBody) -> Bucket: bucket = Bucket(book=body.book, name=body.name, members=body.members) - try: - bucket.check() - except BadRequest as exc: - raise HTTPException(status_code=400, detail=str(exc)) from None - missing = sorted(bucket.venues() - set(engine.adapters)) - if missing: - raise HTTPException(status_code=400, detail=f"this server does not trade {', '.join(missing)}: " - "every member venue needs credentials here") - # Whoever defines a bucket must be able to trade every leg it will route to. - for member_venue in sorted(bucket.venues()): - require(principal, "trade", account_key(None, engine, member_venue)) - return await engine.save_bucket(bucket) + if body.id is not None: + bucket.id = client_bucket_id(body.id) + checked_definition(principal, engine, bucket) + if body.id is not None and await engine.journal.bucket(bucket.id) is not None: + raise HTTPException(status_code=409, detail=f"bucket {bucket.id} already exists") + await engine.save_bucket(bucket) + return Bucket.model_validate(await engine.journal.bucket(bucket.id)) # with its stored times async def _bucket(principal: Principal, engine: Engine, bucket_id: str) -> Bucket: """The bucket a path names, as `` or as its market id `bucket:`.""" @@ -453,12 +605,50 @@ async def get_bucket(principal: Caller, engine: EngineDep, bucket_id: Annotated[ return await _bucket(principal, engine, bucket_id) @router.delete("/buckets/{bucket_id}", tags=["buckets"], summary="Archive a bucket", responses=RESPONSES) - async def archive_bucket(principal: Caller, engine: EngineDep, bucket_id: Annotated[str, Path()]) -> Bucket: + async def archive_bucket(principal: Caller, engine: EngineDep, request: Request, + bucket_id: Annotated[str, Path()]) -> Bucket: bucket = await _bucket(principal, engine, bucket_id) for account in bucket_accounts(bucket, engine): require(principal, "trade", account) await engine.journal.archive_bucket(bucket.id) - return Bucket.model_validate(await engine.journal.bucket(bucket.id)) + archived = Bucket.model_validate(await engine.journal.bucket(bucket.id)) + hub = getattr(request.app.state, "market_streams", None) + if hub is not None: + await hub.refresh_bucket(archived) + return archived + + @router.patch("/buckets/{bucket_id}", tags=["buckets"], summary="Change a bucket", responses=RESPONSES) + async def update_bucket(principal: Caller, engine: EngineDep, request: Request, + bucket_id: Annotated[str, Path()], body: BucketUpdate) -> Bucket: + bucket = await _bucket(principal, engine, bucket_id) + for account in bucket_accounts(bucket, engine): + require(principal, "trade", account) + changed = bucket.model_copy(update={ + **body.model_dump(exclude_none=True, exclude={"members"}), + **({"members": body.members} if body.members is not None else {}), + "updated_ms": None, + }) + checked_definition(principal, engine, changed) + await engine.save_bucket(changed) + saved = Bucket.model_validate(await engine.journal.bucket(bucket.id)) + hub = getattr(request.app.state, "market_streams", None) + if hub is not None: + await hub.refresh_bucket(saved) + return saved + + @router.post("/buckets/{bucket_id}/plan", tags=["buckets"], summary="Preview how an order would route", + responses=RESPONSES) + async def plan_bucket_order(principal: Caller, engine: EngineDep, request: Request, + bucket_id: Annotated[str, Path()], body: BucketPlanBody) -> BucketPlanView: + """The router's plan for a market order on the bucket, on the members' + current books (serve's live streams where it holds them, else a venue + read) and their published taker fees. Nothing is placed.""" + bucket = await _bucket(principal, engine, bucket_id) + books = getattr(request.app.state, "bucket_books", None) + if books is None: + raise HTTPException(status_code=503, detail="this server has no market data attached for buckets") + result = await books.plan(bucket, side=body.side, quantity=body.quantity, worst_price=body.worst_price) + return plan_view(bucket, body, result) @router.get("/buckets/{bucket_id}/position", tags=["buckets"], summary="Position in a bucket", responses=RESPONSES) @@ -475,53 +665,67 @@ async def bucket_position( responses=RESPONSES) async def bucket_orders(principal: Caller, engine: EngineDep, bucket_id: Annotated[str, Path()]) -> PageResponse[BucketOrderReport]: - bucket = await _bucket(principal, engine, bucket_id) - reports = [bucket_report(p) for p in await engine.bucket_orders(bucket.id)] + if await engine.journal.bucket(bucket_id) is None: + raise HTTPException(status_code=404, detail=f"no bucket {bucket_id}") + reports = [bucket_report(p) for p in await engine.bucket_orders(bucket_id) if may_view_order(principal, p.as_order())] return PageResponse[BucketOrderReport](data=reports, next_cursor=None, count=len(reports)) @router.get("/buckets/{bucket_id}/orders/{order_id}", tags=["buckets"], summary="One order on a bucket", responses=RESPONSES) async def bucket_order(principal: Caller, engine: EngineDep, bucket_id: Annotated[str, Path()], order_id: Annotated[str, Path()]) -> BucketOrderReport: - bucket = await _bucket(principal, engine, bucket_id) parent = await engine.bucket_order(order_id) - if parent is None or parent.market_id != bucket.market_id: + if parent is None or parent.market_id != f"bucket:{bucket_id}": raise HTTPException(status_code=404, detail=f"no order {order_id} on bucket {bucket_id}") + if not may_view_order(principal, parent.as_order()): + raise HTTPException(status_code=403, detail="this key may not view every execution account") return bucket_report(parent) - @router.get("/orders", tags=["orders"], summary="Open orders", responses=RESPONSES) + @router.get("/orders", tags=["orders"], summary="Orders: open ones, or every state", responses=RESPONSES) async def list_orders( principal: Caller, engine: EngineDep, venue: Annotated[str | None, Query(description="Only this venue")] = None, book: Annotated[str | None, Query(description="Only this strategy")] = None, + scope: Annotated[Literal["open", "all"], Query( + description="`open` (the default): resting venue orders and live engine-held parents " + "(`waiting`, `triggered`, `pending_cancel`). `all`: every state, finished ones too")] = "open", + market_id: Annotated[str | None, Query(description="Only this market (`venue:native`)")] = None, + since: Annotated[int | None, Query(description="Only orders created at or after this time, " + "milliseconds since the epoch")] = None, + limit: Annotated[int | None, Query(ge=1, le=MAX_ORDERS_PAGE, description=( + f"Rows per page, at most {MAX_ORDERS_PAGE}. Default: every open order for `open`, " + f"{DEFAULT_ORDERS_PAGE} for `all`"))] = None, + cursor: Annotated[str | None, Query(description="`next_cursor` from the previous page")] = None, ) -> PageResponse[Order]: - orders = [o for o in engine.open_orders(venue=venue, book=book) - if visible(principal, (o.account.key if o.account else f"{o.venue}:default"))] - return PageResponse[Order](data=orders, next_cursor=None, count=len(orders)) + """Newest first by creation. A child carries its parent's id in + `parent_id`, so children group under their engine-held parent. A page + may hold fewer rows than `limit` when some are on accounts this key may + not view: follow `next_cursor` until it is null.""" + if limit is None and scope == "all": + limit = DEFAULT_ORDERS_PAGE + rows = await engine.journal.orders_page( + venue=venue, book=book, market_id=market_id, since_ts=since, before=orders_cursor_key(cursor), + open_only=scope == "open", limit=limit) + orders = [current_view(engine, order) for order, _ in rows + if order.user_id in (None, engine.config.user_id) + and may_view_order(principal, order)] + next_cursor = orders_cursor(rows[-1][1]) if limit is not None and len(rows) == limit else None + return PageResponse[Order](data=orders, next_cursor=next_cursor, count=len(orders)) @router.get("/orders/{order_id}", tags=["orders"], summary="One order", responses=RESPONSES) async def get_order(principal: Caller, engine: EngineDep, order_id: Annotated[str, Path()]) -> Order: - parent = engine.orders.get(order_id) - order = parent.as_order() if parent is not None else None - if order is None: - for venue in engine.adapters: - order = await engine.journal.order(venue, order_id) - if order is not None: - break - if order is None: - raise HTTPException(status_code=404, detail=f"no order {order_id}") - account = order.account.key if order.account else f"{order.venue}:default" - if not visible(principal, account): - raise HTTPException(status_code=403, detail="this key may not view that account") + order = await _known(engine, order_id) + if not may_view_order(principal, order): + raise HTTPException(status_code=403, detail="this key may not view every execution account") return order @router.patch("/orders/{order_id}", tags=["orders"], summary="Amend an order", responses=RESPONSES) async def edit_order(principal: Caller, engine: EngineDep, order_id: Annotated[str, Path()], body: EditBody) -> Order: order = await _known(engine, order_id) - account = order.account.key if order.account else f"{order.venue}:default" - require(principal, "trade", account) + if not may_trade_order(principal, order): + raise HTTPException(status_code=403, detail="this key may not trade on every execution account") try: return await engine.edit(EditRequest(order_id=order_id, **body.model_dump(exclude_none=True))) except SynpathError as exc: @@ -533,8 +737,8 @@ async def edit_order(principal: Caller, engine: EngineDep, order_id: Annotated[s @router.delete("/orders/{order_id}", tags=["orders"], summary="Cancel an order", responses=RESPONSES) async def cancel_order(principal: Caller, engine: EngineDep, order_id: Annotated[str, Path()]) -> Order: order = await _known(engine, order_id) - account = order.account.key if order.account else f"{order.venue}:default" - require(principal, "trade", account) + if not may_trade_order(principal, order): + raise HTTPException(status_code=403, detail="this key may not trade on every execution account") try: return await engine.cancel(order_id) except SynpathError as exc: @@ -542,14 +746,10 @@ async def cancel_order(principal: Caller, engine: EngineDep, order_id: Annotated raise async def _known(engine: Engine, order_id: str) -> Order: - parent = engine.orders.get(order_id) - if parent is not None: - return parent.as_order() - for venue in engine.adapters: - order = await engine.journal.order(venue, order_id) - if order is not None: - return order - raise HTTPException(status_code=404, detail=f"no order {order_id}") + try: + return await engine._known(order_id, None) + except OrderNotFound: + raise HTTPException(status_code=404, detail=f"no order {order_id}") from None # -- fills, positions, balances ------------------------------------------ @@ -601,33 +801,58 @@ async def pnl( unrealized=total.unrealized, fees=total.fees, volume=total.volume, positions=total.positions, marked=total.marked)) - # -- fair values ---------------------------------------------------------- - @router.get("/fair-values", tags=["portfolio"], summary="The marks positions are valued at", responses=RESPONSES) - async def list_fair_values(principal: Caller, engine: EngineDep) -> list[FairValueView]: - require(principal, "view") - return [FairValueView(account=account, market_id=market, value=value) - for (account, market), value in engine.fair_values.marks().items() - if visible(principal, account)] + # -- the order log ------------------------------------------------------ - @router.put("/fair-values", tags=["portfolio"], summary="Set a mark", responses=RESPONSES) - async def set_fair_value(principal: Caller, engine: EngineDep, body: FairValueBody) -> FairValueView: - require(principal, "trade", body.account) - mark = await engine.fair_values.set(body.account, body.market_id, body.value, source=body.source) - return FairValueView(account=body.account, market_id=body.market_id, value=mark.value) + @router.get("/order-log", tags=["orders"], summary="Every action on orders, newest first", responses=RESPONSES) + async def get_order_log( + principal: Caller, engine: EngineDep, + order_id: Annotated[str | None, Query(description="One order and its family: its cancel, fills, children")] = None, + limit: Annotated[int, Query(ge=1, le=500)] = 200, + before: Annotated[int | None, Query(description="The `next` of the previous page")] = None, + ) -> dict[str, Any]: + """The journal's order events as a log (`synpath.engine.order_log`): + instructions saved, sends and their outcome, the venue's records, + engine-held parents' states, cancels, fills and risk refusals, with + repeats folded. Read only.""" + require(principal, "view") + from ..engine.order_log import order_log + if order_id: + order = await _known(engine, order_id) + if not may_view_order(principal, order): + raise HTTPException(status_code=403, detail="this key may not view every execution account") + async def allow(event): + return await may_view_event(principal, engine, event) + rows, cursor = await order_log(engine, order_id=order_id, limit=limit, before=before, allow_event=allow) + return {"data": rows, "next": cursor} + + @router.get("/journal", tags=["status"], summary="The engine's journal events, newest first", responses=RESPONSES) + async def get_journal( + principal: Caller, engine: EngineDep, + kind: Annotated[str | None, Query(description="Only kinds starting with this, e.g. `reconcile.` or `engine.`")] = None, + limit: Annotated[int, Query(ge=1, le=500)] = 200, + before: Annotated[int | None, Query(description="The `next` of the previous page")] = None, + ) -> dict[str, Any]: + """The append-only journal as it is, every kind (the order log reads + the order kinds of it, folded). Each event: `seq`, `ts`, `kind`, `key` + and its `payload`. Read only.""" + require(principal, "view") + events = await engine.journal.events_page(prefixes=[kind] if kind else (), before=before, limit=limit) + rows = [{"seq": e.seq, "ts": e.ts, "kind": e.kind, "key": e.key, "payload": e.payload} for e in events if await may_view_event(principal, engine, e)] + return {"data": rows, "next": events[-1].seq if len(events) == limit else None} # -- risk and the kill switch -------------------------------------------- @router.get("/risk", tags=["risk"], summary="The rules in force", responses=RESPONSES) async def get_risk(principal: Caller, engine: EngineDep) -> RiskConfig: require(principal, "view") - return engine.risk.config + return engine.order_risk_checks.config @router.put("/risk", tags=["risk"], summary="Replace the rules", responses=RESPONSES) async def put_risk(principal: Caller, engine: EngineDep, body: RiskConfig) -> RiskConfig: require(principal, "manage_credentials") await engine.set_risk(body, author=principal.user_id) - return engine.risk.config + return engine.order_risk_checks.config @router.post("/halt", tags=["risk"], summary="Stop trading", responses=RESPONSES) async def halt(principal: Caller, engine: EngineDep, body: HaltRequest) -> HaltResult: @@ -642,7 +867,7 @@ async def resume(principal: Caller, engine: EngineDep, scope: Annotated[str | None, Query()] = None) -> HaltState: require(principal, "trade") await engine.resume(scope=scope) - return HaltState(halted=engine.risk.kill.engaged, halt_reason=engine.risk.kill.reason) + return HaltState(halted=engine.order_risk_checks.kill.engaged, halt_reason=engine.order_risk_checks.kill.reason) # -- members, keys and the audit log -------------------------------------- @@ -680,6 +905,11 @@ async def revoke_key(principal: Caller, store: StoreDep, key_id: Annotated[str, require(principal, "manage_credentials") return {"revoked": await store.revoke_key(key_id)} + # -- RFQs, quotes and combos (docs/rfq-combo.md; synpath.server.rfq_routes) -- + + from .rfq_routes import add_rfq_routes + add_rfq_routes(router) + @router.get("/audit", tags=["members"], summary="The audit log, append-only", responses=RESPONSES) async def audit( principal: Caller, @@ -712,8 +942,7 @@ async def events(socket: WebSocket, key: str | None = Query(default=None), if store is None or engine is None: await socket.close(code=1011, reason="this server has no engine attached") return - secret = key or (socket.headers.get("authorization") or "")[7:].strip() - principal = await store.principal(secret) if secret else None + principal = await socket_principal(socket, store, key) if principal is None or not principal.may("view"): await socket.close(code=4401, reason="an access token with view permission is required") return @@ -721,6 +950,10 @@ async def events(socket: WebSocket, key: str | None = Query(default=None), subscription = engine.bus.subscribe(*(wanted or ())) try: async for event in engine.bus.replay(since): + if wanted and not subscription.wants(event): + continue + if not await may_view_event(principal, engine, event): + continue await socket.send_text(json.dumps({ "seq": event.seq, "ts": event.ts, "kind": event.kind, "key": event.key, "payload": event.payload, }, default=str)) @@ -728,6 +961,8 @@ async def events(socket: WebSocket, key: str | None = Query(default=None), event = await subscription.queue.get() if wanted and not subscription.wants(event): continue + if not await may_view_event(principal, engine, event): + continue await socket.send_text(json.dumps({ "seq": event.seq, "ts": event.ts, "kind": event.kind, "key": event.key, "payload": event.payload, "dropped": subscription.dropped, diff --git a/synpath/smarkets.py b/synpath/smarkets.py index d1698f9..2d0eb2d 100644 --- a/synpath/smarkets.py +++ b/synpath/smarkets.py @@ -75,23 +75,23 @@ API_URL = "https://api.smarkets.com" CURRENCY = "GBP" +# Prices are basis points of 100%: 5000 is 50%. PRICE_SCALE = 10_000 -"""Prices are basis points of 100%: 5000 is 50%.""" +# Quantities are 1/100 of a penny: 10,000 is £1 of pot, one contract. QUANTITY_SCALE = 10_000 -"""Quantities are 1/100 of a penny: 10,000 is £1 of pot, one contract.""" +# Smarkets allows 1200 requests per 60 s on an account (API users) and less +# anonymously: an anonymous `/v3/events/` answers `X-RateLimit-Limit: 60`. One a +# second, shared process-wide, stays inside the anonymous budget. LIMITER = RateLimiter(1.0, burst=5) -"""Smarkets allows 1200 requests per 60 s on an account (API users) and less -anonymously: an anonymous `/v3/events/` answers `X-RateLimit-Limit: 60`. One a -second, shared process-wide, stays inside the anonymous budget.""" +# `/quotes/` has its own, stricter limit: 50 per 60 s for API users and, as +# observed anonymously, 20 per 60 s. Shared by the adapter and the stream. QUOTES_LIMITER = RateLimiter(20 / 60, burst=3) -"""`/quotes/` has its own, stricter limit: 50 per 60 s for API users and, as -observed anonymously, 20 per 60 s. Shared by the adapter and the stream.""" +# A session token lasts 30 minutes from its last authenticated request; one +# unused for longer than this is replaced by a fresh login. SESSION_IDLE_LIMIT = 25 * 60.0 -"""A session token lasts 30 minutes from its last authenticated request; one -unused for longer than this is replaced by a fresh login.""" # Batch sizes, as the OpenAPI document caps each route's ids. EVENTS_PER_REQUEST = 300 @@ -103,35 +103,35 @@ LAST_PRICE_MARKETS_PER_REQUEST = 100 TRADE_CONTRACTS_PER_REQUEST = 300 +# Smarkets events read per listing request. Football fixtures hold about a +# hundred markets each, so a page of contracts rarely needs more. EVENT_PAGE = 20 -"""Smarkets events read per listing request. Football fixtures hold about a -hundred markets each, so a page of contracts rarely needs more.""" +# Most event pages one listing call reads while filling a page. MAX_PAGES = 20 -"""Most event pages one listing call reads while filling a page.""" +# Event scopes listed by default: the kinds that hold markets. Roots and +# categories (Football, Premier League) are tree nodes without markets. DEFAULT_SCOPES = ("single_event", "outright", "antepost") -"""Event scopes listed by default: the kinds that hold markets. Roots and -categories (Football, Premier League) are tree nodes without markets.""" +# Smarkets event states for each status a listing asks for. STATES = { "open": ("upcoming", "live"), "closed": ("ended", "suspended"), "settled": ("settled", "cancelled"), "all": ("new", "upcoming", "live", "ended", "settled", "cancelled", "suspended"), } -"""Smarkets event states for each status a listing asks for.""" +# Smarkets' standard commission on net market winnings. DEFAULT_COMMISSION = 0.02 -"""Smarkets' standard commission on net market winnings.""" +# A contract's outcome as the YES result. A reduced contract was withdrawn +# (a non-runner) and its bets voided, the matched ones on other contracts +# reduced by its factor. A dead heat pays the winning stakes divided by the +# number of contracts tied, so it is neither yes nor no. CONTRACT_RESULTS: dict[str, str] = { "winner": "yes", "loser": "no", "voided": "void", "reduced": "void", "deadheat": "dead_heat", } -"""A contract's outcome as the YES result. A reduced contract was withdrawn -(a non-runner) and its bets voided, the matched ones on other contracts -reduced by its factor. A dead heat pays the winning stakes divided by the -number of contracts tied, so it is neither yes nor no.""" USER_AGENT = "synpath (+https://github.com/Synpath-ai/synpath)" diff --git a/synpath/sx_bet.py b/synpath/sx_bet.py index 703aef7..11949ea 100644 --- a/synpath/sx_bet.py +++ b/synpath/sx_bet.py @@ -49,34 +49,35 @@ TESTNET_API_URL = "https://api.toronto.sx.bet" VENUE = "sx_bet" +# The market routes share 500 requests a minute per IP; kept under it, +# shared process-wide. LIMITER = RateLimiter(6.0, burst=10) -"""The market routes share 500 requests a minute per IP; kept under it, -shared process-wide.""" +# Rows per listing page: the venue's maximum. PAGE = 100 -"""Rows per listing page: the venue's maximum.""" +# Most listing pages one walk reads: 20,000 markets, against about 12,000 today. MAX_PAGES = 200 -"""Most listing pages one walk reads: 20,000 markets, against about 12,000 today.""" +# Prices are implied probabilities scaled by 1e20. ODDS_SCALE = 10 ** 20 -"""Prices are implied probabilities scaled by 1e20.""" +# Stakes and returns are USDC in 6-decimal base units. USDC = 1_000_000 -"""Stakes and returns are USDC in 6-decimal base units.""" +# The odds ladder steps by 0.125%. TICK = 0.00125 -"""The odds ladder steps by 0.125%.""" +# 1% of the profit on a winning bet, for the taker; a maker pays nothing. TAKER_FEE = 0.01 -"""1% of the profit on a winning bet, for the taker; a maker pays nothing.""" +# Hashes one `/markets/find` request takes. MAX_FIND = 30 -"""Hashes one `/markets/find` request takes.""" +# Most tape pages one candle read walks back through: 2,000 trades. MAX_TRADE_PAGES = 20 -"""Most tape pages one candle read walks back through: 2,000 trades.""" +# The venue's market types by id, as its reference names them. MARKET_TYPES: dict[int, str] = { 1: "1X2", 2: "Under/Over", 3: "Asian Handicap", 17: "Both Teams To Score", 21: "Under/Over First Period", 28: "Under/Over Including Overtime", 29: "Under/Over Rounds", @@ -92,14 +93,13 @@ 835: "Asian Under/Over", 866: "Set Spread", 995: "Anytime Touchdown Scorer", 1536: "Under/Over Maps", 1618: "1st 5 Innings Winner-12", } -"""The venue's market types by id, as its reference names them.""" +# A market hash. The venue refuses a whole lookup over one malformed hash, +# so they are checked here first. HASH = re.compile(r"0x[0-9a-f]{64}") -"""A market hash. The venue refuses a whole lookup over one malformed hash, -so they are checked here first.""" +# A settled market's reported outcome: side one, side two, or void. WINNERS = {1: "yes", 2: "no", 0: "void"} -"""A settled market's reported outcome: side one, side two, or void.""" def odds(value: Any) -> float | None: diff --git a/synpath/trading/__init__.py b/synpath/trading/__init__.py index aad1025..45c0903 100644 --- a/synpath/trading/__init__.py +++ b/synpath/trading/__init__.py @@ -25,18 +25,27 @@ MarketHalted, OrderNotFound, OrderRejected, + RfqNotFound, PermissionDenied, RateBudgetExceeded, RiskRejected, + CancelUnconfirmed, + SubmissionInDoubt, + SubmissionPending, ) from .types import ( + AcceptQuote, Account, Balance, + Combo, + ComboMarket, + ComboLeg, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, + MakerQuote, Order, OrderRequest, OrderStatus, @@ -44,6 +53,11 @@ Position, PositionSide, Precision, + QuoteStatus, + Rfq, + RfqCloseReason, + RfqQuote, + RfqStatus, Settlement, SettlementState, Side, @@ -90,7 +104,8 @@ def __getattr__(name: str): "Account", "Balance", "EditRequest", "FeeEstimate", "Fill", "HeldBy", "Liquidity", "Order", "OrderRequest", "OrderStatus", "OrderType", "Position", "PositionSide", "Precision", "Settlement", "SettlementState", "Side", "TimeInForce", "TriggerSource", + "Rfq", "RfqQuote", "RfqStatus", "RfqCloseReason", "QuoteStatus", "Combo", "ComboLeg", "ComboMarket", "AcceptQuote", "MakerQuote", "CredentialsMissing", "DuplicateClientOrderId", "InsufficientFunds", "InvalidOrder", - "MarketHalted", "OrderNotFound", "OrderRejected", "PermissionDenied", - "RateBudgetExceeded", "RiskRejected", + "MarketHalted", "OrderNotFound", "OrderRejected", "RfqNotFound", "PermissionDenied", + "RateBudgetExceeded", "RiskRejected", "CancelUnconfirmed", "SubmissionInDoubt", "SubmissionPending", ] diff --git a/synpath/trading/base.py b/synpath/trading/base.py index d95cba4..a799275 100644 --- a/synpath/trading/base.py +++ b/synpath/trading/base.py @@ -14,7 +14,7 @@ from abc import ABC, abstractmethod from decimal import Decimal -from typing import Any +from typing import Any, Callable from .._deprecated import renamed_kwargs from ..base import Capability, complete_capabilities @@ -22,7 +22,8 @@ from ..types import Page from .types import ( Side, - Account, Balance, EditRequest, FeeEstimate, Fill, Order, OrderRequest, Position, Settlement, + Account, Balance, EditRequest, FeeEstimate, Fill, Order, OrderRequest, Position, Precision, Settlement, + AcceptQuote, Action, Combo, ComboLeg, ComboMarket, MakerQuote, Outcome, QuoteStatus, Rfq, RfqQuote, RfqStatus, ) @@ -47,6 +48,10 @@ async def create_order(self, request: OrderRequest) -> Order: """Place one order the venue holds: a limit, or a market where the venue has them. Anything else belongs to the engine.""" + async def create_order_with_precision(self, request: OrderRequest, precision: Precision) -> Order: + """Use supplied reference rules where supported; other adapters load their own.""" + return await self.create_order(request) + async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]: """Many orders in one call, one result per request in the order asked for. A request the venue refused comes back as its exception rather @@ -117,6 +122,134 @@ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, q """What an order on this market would cost, in the YES price.""" raise NotSupported(f"{self.id}: fetch_fee_estimate") + # -- combos (docs/rfq-combo.md) ------------------------------------------- + # + # Answered where `has["combos"]` is True. A combo is an instrument with + # its own market id; its position, P&L and settlement live there. + + async def create_combo(self, legs: list[ComboLeg], *, collection_id: str | None = None) -> Combo: + """The venue's combo for these legs, created if it does not exist. + Where the venue keeps one canonical combo per set of legs (Kalshi, + Polymarket US) this is idempotent: the same legs return the same id. + `collection_id` names where to build it, on a venue that builds combos + in collections (Kalshi); a venue without collections refuses it.""" + raise NotSupported(f"{self.id}: create_combo") + + async def fetch_combo_eligible_markets( + self, *, event_id: str | None = None, tag: str | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[ComboMarket]: + """The markets the venue lets a combo take as legs, from its own list, + one page at a time. `event_id` and `tag` narrow it where the venue can + (each adapter says how). Kalshi lists collections instead + (`fetch_combo_collections`).""" + raise NotSupported(f"{self.id}: fetch_combo_eligible_markets") + + async def fetch_combo(self, combo_id: str) -> Combo: + """One combo by its own market id, with its legs.""" + raise NotSupported(f"{self.id}: fetch_combo") + + async def lookup_combo(self, legs: list[ComboLeg]) -> Combo | None: + """The existing combo for these legs without creating one, or `None`. + Only where the venue has a read-only lookup by legs.""" + raise NotSupported(f"{self.id}: lookup_combo") + + # -- RFQ, requester (taker) side ------------------------------------------ + # + # Answered where `has["rfq_taker"]` is True. Every price is a YES price. + + async def create_rfq( + self, instrument_id: str, *, quantity: Decimal | None = None, amount: Decimal | None = None, + outcome: Outcome | None = None, action: Action | None = None, rest_remainder: bool = False, + combo: Combo | None = None, + ) -> Rfq: + """Ask for quotes on a market or a combo (`instrument_id` is either's + market id), sized by contracts (`quantity`) or money (`amount`), + exactly one. `outcome`/`action` only where the venue asks one side.""" + raise NotSupported(f"{self.id}: create_rfq") + + async def fetch_rfq(self, rfq_id: str) -> Rfq: + raise NotSupported(f"{self.id}: fetch_rfq") + + async def fetch_rfqs( + self, *, status: RfqStatus | str | None = None, market_id: str | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Rfq]: + """This account's own RFQs.""" + raise NotSupported(f"{self.id}: fetch_rfqs") + + async def cancel_rfq(self, rfq_id: str) -> None: + """Close one of this account's open RFQs. Read it back with `fetch_rfq`.""" + raise NotSupported(f"{self.id}: cancel_rfq") + + async def fetch_quotes(self, rfq_id: str, *, status: QuoteStatus | str | None = None) -> list[RfqQuote]: + """The quotes visible on one RFQ: every maker's on our own RFQ, our + own on someone else's.""" + raise NotSupported(f"{self.id}: fetch_quotes") + + async def fetch_quote(self, quote_id: str, *, rfq_id: str | None = None) -> RfqQuote: + raise NotSupported(f"{self.id}: fetch_quote") + + async def accept_quote(self, accept: AcceptQuote) -> RfqQuote: + """Take one side of a quote. Returns at once with the quote in its new + state (`accepted`); the maker's confirm and the orders come after. + Refused before sending when the quote is worse than `worst_price`.""" + raise NotSupported(f"{self.id}: accept_quote") + + # -- RFQ, quoter (maker) side --------------------------------------------- + # + # Answered where `has["rfq_quoter"]` is True. + + async def fetch_incoming_rfqs( + self, *, market_id: str | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Rfq]: + """Open RFQs from other accounts that this account may quote.""" + raise NotSupported(f"{self.id}: fetch_incoming_rfqs") + + async def send_quote(self, quote: MakerQuote) -> RfqQuote: + raise NotSupported(f"{self.id}: send_quote") + + async def replace_quote(self, quote_id: str, quote: MakerQuote) -> RfqQuote: + """Swap a live quote for new prices. The result may carry a new id.""" + raise NotSupported(f"{self.id}: replace_quote") + + async def cancel_quote(self, quote_id: str, *, rfq_id: str | None = None) -> None: + """Withdraw a quote, or decline an accepted one during the last look.""" + raise NotSupported(f"{self.id}: cancel_quote") + + async def confirm_quote(self, quote: RfqQuote) -> RfqQuote: + """Confirm an accepted quote (the maker's last look). Commits capital.""" + raise NotSupported(f"{self.id}: confirm_quote") + + async def fetch_my_quotes( + self, *, rfq_id: str | None = None, status: QuoteStatus | str | None = None, + ) -> list[RfqQuote]: + """Quotes this account made.""" + raise NotSupported(f"{self.id}: fetch_my_quotes") + + # -- RFQ streams and restarts (optional hooks) ---------------------------- + + def subscribe_incoming_rfqs(self, listener: Callable[[Rfq], Any]) -> Callable[[], None] | None: + """Call `listener(rfq)` (sync or async) for each incoming RFQ as the + venue pushes it, and return the unsubscribe. `None` (the default) + means the venue has no such stream here, so the engine polls + `fetch_incoming_rfqs`. Registering starts nothing; see + `start_incoming_rfqs`.""" + return None + + async def start_incoming_rfqs(self) -> None: + """Start the stream that feeds `subscribe_incoming_rfqs` listeners, + once (a no-op where there is none, or where it already runs).""" + return None + + def restore_rfqs(self, rfqs: list[Rfq], quotes: list[RfqQuote] | None = None) -> None: + """After a restart, re-seed what the venue cannot list back: the + RFQs the engine created and still holds open, and the quotes whose + accept (`mine=False`) or confirm (`mine=True`) was in doubt, each + with the status the engine last knew. A venue that lists its own + RFQs and quotes ignores this (the default).""" + return None + # -- housekeeping --------------------------------------------------------- async def close(self) -> None: diff --git a/synpath/trading/credentials.py b/synpath/trading/credentials.py index 3037377..bc7aa06 100644 --- a/synpath/trading/credentials.py +++ b/synpath/trading/credentials.py @@ -68,10 +68,10 @@ def _redacted_repr(self: Any) -> str: @dataclass(frozen=True, repr=False) class KalshiCredentials: key_id: str + # RSA private key, PEM. Signs `timestamp + method + path` per request. private_key_pem: bytes = field(repr=False) - """RSA private key, PEM. Signs `timestamp + method + path` per request.""" + # `prod` trades on Kalshi itself; `demo` on its practice exchange. env: Literal["demo", "prod"] = "prod" - """`prod` trades on Kalshi itself; `demo` on its practice exchange.""" _public = ("key_id", "env") __repr__ = _redacted_repr @@ -80,41 +80,41 @@ def secrets(self) -> list[str]: return [self.private_key_pem.decode(errors="ignore")] +# Synpath's Polymarket builder code, attached to orders unless the account +# sets its own or opts out (`POLYMARKET_BUILDER_CODE=none`). Its builder fee +# rate is 0: it adds nothing to what an order costs. SYNPATH_BUILDER_CODE = "0x3373b88f438d41079965d0742b16c602aa932cbc3b7ee589db36cbf85778c869" -"""Synpath's Polymarket builder code, attached to orders unless the account -sets its own or opts out (`POLYMARKET_BUILDER_CODE=none`). Its builder fee -rate is 0: it adds nothing to what an order costs.""" BUILDER_CODE = re.compile(r"^0x[0-9a-fA-F]{64}$") @dataclass(frozen=True, repr=False) class PolymarketCredentials: + # The signer's private key, hex. Signs every order (EIP-712) and, when + # no API credentials are given, derives them. private_key: str = field(repr=False) - """The signer's private key, hex. Signs every order (EIP-712) and, when - no API credentials are given, derives them.""" + # Which wallet the orders spend from: 0 an EOA (allowlisted accounts + # only), 1 a legacy Magic/Google proxy wallet, 2 a legacy Safe, 3 a + # Deposit Wallet -- the default for every account created since + # 2026-05-04. signature_type: int = 0 - """Which wallet the orders spend from: 0 an EOA (allowlisted accounts - only), 1 a legacy Magic/Google proxy wallet, 2 a legacy Safe, 3 a - Deposit Wallet -- the default for every account created since - 2026-05-04.""" + # The account wallet that holds the pUSD and tokens. Required for types + # 1-3; for an EOA it is the signer's own address. funder: str | None = None - """The account wallet that holds the pUSD and tokens. Required for types - 1-3; for an EOA it is the signer's own address.""" api_key: str | None = None api_secret: str | None = field(default=None, repr=False) + # CLOB API credentials, when already created. Derived from the wallet + # key on first use otherwise. api_passphrase: str | None = field(default=None, repr=False) - """CLOB API credentials, when already created. Derived from the wallet - key on first use otherwise.""" + # A bytes32 builder code attached to every order for attribution. Public, + # not a secret. Synpath's by default; None attaches none. builder_code: str | None = SYNPATH_BUILDER_CODE - """A bytes32 builder code attached to every order for attribution. Public, - not a secret. Synpath's by default; None attaches none.""" relayer_api_key: str | None = field(default=None, repr=False) + # A Relayer API key (polymarket.com -> Settings -> API Keys) for gasless + # wallet transactions: approvals, split, merge, redeem. relayer_api_key_address: str | None = None - """A Relayer API key (polymarket.com -> Settings -> API Keys) for gasless - wallet transactions: approvals, split, merge, redeem.""" + # A Polygon JSON-RPC endpoint, for an EOA's own on-chain transactions. rpc_url: str | None = None - """A Polygon JSON-RPC endpoint, for an EOA's own on-chain transactions.""" _public = ("signature_type", "funder", "api_key", "builder_code", "relayer_api_key_address") __repr__ = _redacted_repr @@ -129,9 +129,9 @@ class PolymarketUSCredentials: after identity verification in the app.""" key_id: str + # Base64 Ed25519 private key, shown once at creation. Signs + # `timestamp + METHOD + path` on every request. secret_key: str = field(repr=False) - """Base64 Ed25519 private key, shown once at creation. Signs - `timestamp + METHOD + path` on every request.""" _public = ("key_id",) __repr__ = _redacted_repr @@ -147,14 +147,14 @@ class PolymarketUSExchangeCredentials: participant id per user.""" client_id: str + # RSA private key, PEM. Signs the client-assertion JWT exchanged for a + # three-minute access token. private_key_pem: bytes = field(repr=False) - """RSA private key, PEM. Signs the client-assertion JWT exchanged for a - three-minute access token.""" + # `firms//users/`, exactly as issued at onboarding. participant_id: str - """`firms//users/`, exactly as issued at onboarding.""" + # The trading account orders are placed on. The first account the user + # may trade when not given. account: str | None = None - """The trading account orders are placed on. The first account the user - may trade when not given.""" env: Literal["preprod", "prod"] = "preprod" _public = ("client_id", "participant_id", "account", "env") __repr__ = _redacted_repr @@ -168,15 +168,15 @@ def secrets(self) -> list[str]: class OpinionCredentials: """Opinion: the wallet that signs orders, and the account's API key.""" + # The signer's private key, hex: the wallet connected on opinion.trade. + # Signs every order (EIP-712) for the account's Safe. private_key: str = field(repr=False) - """The signer's private key, hex: the wallet connected on opinion.trade. - Signs every order (EIP-712) for the account's Safe.""" + # The Open API key, from `POST /auth/api-key` signed by the same wallet, + # or the venue's application form. api_key: str = field(repr=False) - """The Open API key, from `POST /auth/api-key` signed by the same wallet, - or the venue's application form.""" + # The account's Safe, which holds the USDT and tokens ("MyProfile" on + # opinion.trade). Read from the venue on first use when not given. multisig_address: str | None = None - """The account's Safe, which holds the USDT and tokens ("MyProfile" on - opinion.trade). Read from the venue on first use when not given.""" _public = ("multisig_address",) __repr__ = _redacted_repr @@ -189,15 +189,15 @@ def secrets(self) -> list[str]: class HyperliquidCredentials: """Hyperliquid: the key that signs, and the account it signs for.""" + # The signer's private key, hex: the account's own wallet, or an API + # wallet the account approved on app.hyperliquid.xyz (More -> API), which + # can trade but never withdraw. private_key: str = field(repr=False) - """The signer's private key, hex: the account's own wallet, or an API - wallet the account approved on app.hyperliquid.xyz (More -> API), which - can trade but never withdraw.""" + # The account the orders are for. Needed when `private_key` is an API + # wallet; with the account's own key it is that key's address. account_address: str | None = None - """The account the orders are for. Needed when `private_key` is an API - wallet; with the account's own key it is that key's address.""" + # Sign for and talk to the test network, where outcomes trade for test funds. testnet: bool = False - """Sign for and talk to the test network, where outcomes trade for test funds.""" _public = ("account_address", "testnet") __repr__ = _redacted_repr @@ -220,19 +220,19 @@ class PredictFunCredentials: """predict.fun: the key that signs, the account it signs for, and the API key mainnet needs.""" + # The signer's private key, hex: a plain wallet's own, or, for a + # Predict account, its owner key (the Privy wallet, exported from + # predict.fun's account settings). private_key: str = field(repr=False) - """The signer's private key, hex: a plain wallet's own, or, for a - Predict account, its owner key (the Privy wallet, exported from - predict.fun's account settings).""" + # The mainnet API key (developers.predict.fun). The test network needs none. api_key: str | None = field(default=None, repr=False) - """The mainnet API key (developers.predict.fun). The test network needs none.""" + # The Predict account (the deposit address) the key signs for. Unset, + # the key's own wallet trades. account_address: str | None = None - """The Predict account (the deposit address) the key signs for. Unset, - the key's own wallet trades.""" + # Trade on BNB Chain's test network, for test USDT. testnet: bool = False - """Trade on BNB Chain's test network, for test USDT.""" + # A BNB Chain node for the balance read. A public one when unset. rpc_url: str | None = None - """A BNB Chain node for the balance read. A public one when unset.""" _public = ("account_address", "testnet", "rpc_url") __repr__ = _redacted_repr @@ -255,15 +255,15 @@ class LimitlessCredentials: """Limitless: the wallet that signs orders, and the scoped API token that signs every request.""" + # The wallet's private key, hex: the wallet connected on limitless.exchange, + # in EOA trading mode. Signs every order (EIP-712). private_key: str = field(repr=False) - """The wallet's private key, hex: the wallet connected on limitless.exchange, - in EOA trading mode. Signs every order (EIP-712).""" + # The scoped API token's id (limitless.exchange, profile, API tokens, Derive). token_id: str - """The scoped API token's id (limitless.exchange, profile, API tokens, Derive).""" + # The token's secret, base64, shown once when the token is derived. secret: str = field(repr=False) - """The token's secret, base64, shown once when the token is derived.""" + # A Base node for the balance read. A public one when unset. rpc_url: str | None = None - """A Base node for the balance read. A public one when unset.""" _public = ("token_id", "rpc_url") __repr__ = _redacted_repr @@ -287,17 +287,17 @@ class SxBetCredentials: account's orders, fills, positions and balance, and every cancel. Only a new order is also an EIP-712 message signed by the wallet.""" + # The SX Bet API key, generated on sx.bet under Account -> Overview -> + # API Credentials. It belongs to the account's own address. api_key: str = field(repr=False) - """The SX Bet API key, generated on sx.bet under Account -> Overview -> - API Credentials. It belongs to the account's own address.""" + # The wallet's private key, hex: the account's own address, never the + # proxy wallet. Signs every order (EIP-712); unset, orders cannot be placed. private_key: str | None = field(default=None, repr=False) - """The wallet's private key, hex: the account's own address, never the - proxy wallet. Signs every order (EIP-712); unset, orders cannot be placed.""" + # The account's wallet address, for the account stream's channels when + # no private key names it. Public: the 0x address, not the key. account_address: str | None = None - """The account's wallet address, for the account stream's channels when - no private key names it. Public: the 0x address, not the key.""" + # The Toronto test network (api.toronto.sx.bet) instead of mainnet. testnet: bool = False - """The Toronto test network (api.toronto.sx.bet) instead of mainnet.""" _public = ("account_address", "testnet") __repr__ = _redacted_repr @@ -322,17 +322,17 @@ class BetfairCredentials: call, market data included, needs both: the key names the application, the login's session token the account.""" + # The application key (delayed or live) from the account's developer + # settings. Sent on every request as `X-Application`. app_key: str = field(repr=False) - """The application key (delayed or live) from the account's developer - settings. Sent on every request as `X-Application`.""" username: str password: str = field(repr=False) cert_path: str | None = None + # A self-signed client certificate and its key, for Betfair's + # non-interactive (certificate) login. Without them the login is interactive. key_path: str | None = None - """A self-signed client certificate and its key, for Betfair's - non-interactive (certificate) login. Without them the login is interactive.""" + # The Betfair site the account is registered on: com, it, es, ro or com.au. jurisdiction: str = "com" - """The Betfair site the account is registered on: com, it, es, ro or com.au.""" _public = ("username", "cert_path", "jurisdiction") __repr__ = _redacted_repr @@ -363,6 +363,8 @@ def secrets(self) -> list[str]: | BetfairCredentials | SmarketsCredentials ) +# What each venue reads. The first entry is the one whose absence means +# "not configured"; the rest are optional or have defaults. ENV_NAMES: dict[str, tuple[str, ...]] = { "kalshi": ("KALSHI_KEY_ID", "KALSHI_PRIVATE_KEY_PATH", "KALSHI_ENV"), "polymarket": ( @@ -390,8 +392,6 @@ def secrets(self) -> list[str]: ), "smarkets": ("SMARKETS_USERNAME", "SMARKETS_PASSWORD"), } -"""What each venue reads. The first entry is the one whose absence means -"not configured"; the rest are optional or have defaults.""" def read_dotenv(path: Path | str) -> dict[str, str]: @@ -750,16 +750,16 @@ def secrets(self) -> list[str]: return [self.api_key] +# The venues whose market data needs an API key but no private key: the +# variable holding the key, and the one choosing the test network. Kalshi and +# Polymarket US sign even their market streams with the full key pair, so +# their trading credentials are their market-data credentials; Polymarket, +# Hyperliquid and Limitless serve market data to anyone. MARKET_DATA_KEYS: dict[str, tuple[str, str | None]] = { "opinion": ("OPINION_API_KEY", None), "predict_fun": ("PREDICT_FUN_API_KEY", "PREDICT_FUN_TESTNET"), "sx_bet": ("SX_BET_API_KEY", "SX_BET_TESTNET"), } -"""The venues whose market data needs an API key but no private key: the -variable holding the key, and the one choosing the test network. Kalshi and -Polymarket US sign even their market streams with the full key pair, so -their trading credentials are their market-data credentials; Polymarket, -Hyperliquid and Limitless serve market data to anyone.""" def _truthy(value: str | None) -> bool: diff --git a/synpath/trading/errors.py b/synpath/trading/errors.py index d354f10..ce4c29e 100644 --- a/synpath/trading/errors.py +++ b/synpath/trading/errors.py @@ -8,12 +8,17 @@ ahead of the request was already too long for it to be honoured in time -- which is deliberately not `RateLimitExceeded`: nothing was sent, and waiting longer is the wrong answer for an order that had a deadline. + +The engine adds three that say an outcome is not yet known, rather than +that something failed: `SubmissionInDoubt` (this send got no verdict), +`SubmissionPending` (a retry of a send still being reconciled) and +`CancelUnconfirmed` (the venue neither cancelled the order nor knows it). """ from __future__ import annotations from typing import Any -from ..errors import AuthenticationError, BadRequest, ExchangeError, SynpathError +from ..errors import AuthenticationError, BadRequest, ExchangeError, NetworkError, SynpathError class InvalidOrder(BadRequest): @@ -39,6 +44,11 @@ class OrderNotFound(ExchangeError): """No order under that id at the venue -- already gone, or never there.""" +class RfqNotFound(OrderNotFound): + """No RFQ, quote or combo under that id at the venue, or none visible to + this account.""" + + class OrderRejected(ExchangeError): """The venue took the order and rejected it: post-only would have crossed, the market is closed, self-trade prevention fired. `info` says which.""" @@ -92,3 +102,33 @@ class RateBudgetExceeded(SynpathError): def __init__(self, message: str, *, wait_s: float): super().__init__(message) self.wait_s = wait_s + + +class SubmissionPending(NetworkError): + """An earlier send has no definitive answer. Retrying must not resend it. + + Raised to a retry of a `client_order_id` whose first send is still being + reconciled; the same retry later returns the order or its refusal.""" + + def __init__(self, message: str, *, client_order_id: str | None = None): + super().__init__(message) + self.client_order_id = client_order_id + + +class SubmissionInDoubt(NetworkError): + """This send reached the venue, or may have, and no answer came back. + + The intent stays `sending` and is reconciled with the venue by its client + order id; retrying with the same `client_order_id` returns the order once + it is found and never sends a second one.""" + + def __init__(self, message: str, *, client_order_id: str | None = None): + super().__init__(message) + self.client_order_id = client_order_id + + +class CancelUnconfirmed(OrderNotFound): + """The venue would not cancel the order and has no record of it either. + + Not found is not cancelled: the order may have filled or expired. Its last + observed status stands until the venue reports otherwise.""" diff --git a/synpath/trading/hyperliquid.py b/synpath/trading/hyperliquid.py index 2e3421f..b13d1e3 100644 --- a/synpath/trading/hyperliquid.py +++ b/synpath/trading/hyperliquid.py @@ -5,8 +5,8 @@ `#<10 * outcome + 1>` for NO, each with its own book, orders and fills, and the asset id `100_000_000 + ` on the wire. Every record here is turned onto the market's YES leg: buying NO at 0.30 is selling at 0.70, as on -Polymarket. An order goes to the token it buys -- `buy` buys YES, `sell` -buys NO -- and with `reduce_only` it sells the other token held instead. +Polymarket. An order buys or sells the token its `outcome` and `action` +name; with neither, `buy` buys YES and `sell` buys NO. Fills carry a direction (`dir`) besides their side. `Buy` and `Sell` are trades. `Split Outcome` and `Merge Outcome` turn quote into a YES and NO @@ -50,23 +50,23 @@ MAINNET_URL = "https://api.hyperliquid.xyz" TESTNET_URL = "https://api.hyperliquid-testnet.xyz" +# An outcome coin's asset id is this plus its number (`#75440` -> 100_075_440). OUTCOME_ASSET_OFFSET = 100_000_000 -"""An outcome coin's asset id is this plus its number (`#75440` -> 100_075_440).""" +# The venue's minimum order value, in the quote token, counted on the token bought. MIN_NOTIONAL = Decimal("10") -"""The venue's minimum order value, in the quote token, counted on the token bought.""" +# Significant figures a price may have. MAX_SIGNIFICANT = 5 -"""Significant figures a price may have.""" QUOTE = "USDC" +# Fill directions that are trades. The others (`Split Outcome`, `Merge +# Outcome`, `Settlement`) move quote and outcome tokens without one. TRADE_DIRECTIONS = frozenset({"Buy", "Sell"}) -"""Fill directions that are trades. The others (`Split Outcome`, `Merge -Outcome`, `Settlement`) move quote and outcome tokens without one.""" +# The venue's `tif`. `Alo` (add liquidity only) is a post-only GTC. TIME_IN_FORCE = {"Gtc": TimeInForce.GTC, "Ioc": TimeInForce.IOC, "Alo": TimeInForce.GTC, "FrontendMarket": TimeInForce.IOC} -"""The venue's `tif`. `Alo` (add liquidity only) is a post-only GTC.""" def D(value: Any) -> Decimal: @@ -122,6 +122,8 @@ def order_of(raw: dict[str, Any], *, account: Account | None = None) -> Order | account=account, market_id=ids.qualify(VENUE, native), side=side, + outcome=outcome, + action=venue_side.lower(), type=OrderType.MARKET if str(order.get("orderType") or "").lower() == "market" else OrderType.LIMIT, time_in_force=tif, status=status, @@ -130,7 +132,6 @@ def order_of(raw: dict[str, Any], *, account: Account | None = None) -> Order | filled=filled, remaining=left if status == OrderStatus.OPEN else Decimal("0"), post_only=order.get("tif") == "Alo", - reduce_only=bool(order.get("reduceOnly")), created_at=int(order["timestamp"]) if order.get("timestamp") else None, updated_at=int(raw["statusTimestamp"]) if raw.get("statusTimestamp") else None, info={**raw, "outcome_side": outcome, "native_status": word}, @@ -335,6 +336,10 @@ class HyperliquidTrading(TradingExchange): "fetch_queue_position": False, "fetch_fee_estimate": True, "rfq": False, + # Typed RFQ and combos arrive phase by phase (docs/rfq-combo.md). + "rfq_taker": False, + "rfq_quoter": False, + "combos": False, "split_merge": False, "watch_orders": False, "watch_my_trades": False, @@ -357,8 +362,8 @@ def __init__( self.credentials = credentials self.signer = WalletSigner(credentials.private_key) + # The account whose orders, fills and balances these are. self.address = credentials.address - """The account whose orders, fills and balances these are.""" self.mainnet = not credentials.testnet self.account = Account(venue=VENUE, name=account_name) # 1200 request weight a minute per IP; an action weighs 1, most info reads 20. @@ -370,8 +375,8 @@ def __init__( from ..hyperliquid import Hyperliquid catalog = Hyperliquid(testnet=not self.mainnet) + # The read adapter: fee scales for estimates. self.catalog = catalog - """The read adapter: fee scales for estimates.""" self._last_nonce = 0 self._nonce_lock = asyncio.Lock() @@ -445,7 +450,8 @@ def _placed(self, request: OrderRequest, native: str, status: Any, wire: dict[st remaining=quantity - done if state == OrderStatus.OPEN else Decimal("0"), average_price=(avg if outcome == "yes" else Decimal("1") - avg) if avg is not None else None, post_only=request.post_only, - reduce_only=request.reduce_only, + outcome=request.leg[0], + action=request.leg[1], created_at=int(time.time() * 1000), book=request.book, trader=request.trader, diff --git a/synpath/trading/hyperliquid_signing.py b/synpath/trading/hyperliquid_signing.py index 759dbab..1d0ff6f 100644 --- a/synpath/trading/hyperliquid_signing.py +++ b/synpath/trading/hyperliquid_signing.py @@ -138,11 +138,11 @@ def _type_hash(signature: str) -> bytes: return keccak(text=signature) +# The `Exchange` domain every L1 action is signed in: chain id 1337, no contract. DOMAIN_SEPARATOR = keccak( _type_hash("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)") + keccak(text="Exchange") + keccak(text="1") + (1337).to_bytes(32, "big") + bytes(32) ) -"""The `Exchange` domain every L1 action is signed in: chain id 1337, no contract.""" AGENT_TYPE_HASH = _type_hash("Agent(string source,bytes32 connectionId)") diff --git a/synpath/trading/init.py b/synpath/trading/init.py index 8753ae2..34c91ee 100644 --- a/synpath/trading/init.py +++ b/synpath/trading/init.py @@ -278,6 +278,8 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: return {"SMARKETS_USERNAME": username, "SMARKETS_PASSWORD": password} +# Each venue, the settings any of which means it is set up already (an API +# key alone is, for market data), and the questions. VENUES: list[tuple[str, tuple[str, ...], Callable[[Prompter], dict[str, str]]]] = [ ("Kalshi", ("KALSHI_KEY_ID",), ask_kalshi), ("Polymarket", ("POLYMARKET_PRIVATE_KEY",), ask_polymarket), @@ -290,8 +292,6 @@ def ask_smarkets(p: Prompter) -> dict[str, str]: ("Betfair", ("BETFAIR_APP_KEY",), ask_betfair), ("Smarkets (optional login; market data needs none)", ("SMARKETS_USERNAME",), ask_smarkets), ] -"""Each venue, the settings any of which means it is set up already (an API -key alone is, for market data), and the questions.""" def _quote(value: str) -> str: diff --git a/synpath/trading/instruments.py b/synpath/trading/instruments.py index 509a8fd..a5ab0c2 100644 --- a/synpath/trading/instruments.py +++ b/synpath/trading/instruments.py @@ -32,9 +32,10 @@ # Shares carry two decimals; the minimum (usually 5) is per market, read # from its book by the trading adapter. "polymarket": {"default_tick": Decimal("0.01"), "min_quantity": Decimal("5"), "quantity_step": Decimal("0.01"), "whole": False}, - # Every live market trades whole contracts today, but a market may set a - # fractional `minimumTradeQty`; the trading adapters read it per market. - "polymarket_us": {"default_tick": Decimal("0.001"), "min_quantity": Decimal("1"), "quantity_step": Decimal("1"), "whole": True}, + # Whole-contract fallback until per-market `minimumTradeQty` is loaded. + # Live crypto markets can be fractional; the adapter reads their rules. + "polymarket_us": {"default_tick": Decimal("0.001"), "min_quantity": Decimal("1"), "quantity_step": Decimal("1"), "whole": True, + "min_price": Decimal("0.01"), "max_price": Decimal("0.99")}, # Prices to a tenth of a cent on every book read, and at least 5 USDT an # order, counted on the token actually bought. "opinion": {"default_tick": Decimal("0.001"), "min_quantity": Decimal("1"), "quantity_step": Decimal("0.01"), "whole": False, @@ -72,23 +73,23 @@ class MarketSpec(BaseModel): model_config = ConfigDict(extra="forbid") + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" venue: str precision: Precision + # Accepting orders right now, as far as the catalog knew. tradable: bool - """Accepting orders right now, as far as the catalog knew.""" tradable_yes: bool = True + # Per side, where a venue says (Polymarket US). Both true elsewhere. tradable_no: bool = True - """Per side, where a venue says (Polymarket US). Both true elsewhere.""" status: str close_at: int | None = None resolution_at: int | None = None + # A per-market cap the venue imposes, where it publishes one. position_limit: Decimal | None = None - """A per-market cap the venue imposes, where it publishes one.""" yes_token: str | None = None + # The venue's own ids for the two sides, where it has them (Polymarket). no_token: str | None = None - """The venue's own ids for the two sides, where it has them (Polymarket).""" read_at: int = Field(default_factory=lambda: int(time.time() * 1000)) info: dict[str, Any] = Field(default_factory=dict) @@ -97,8 +98,8 @@ def age_s(self, now_ms: int | None = None) -> float: return max(0.0, (now - self.read_at) / 1000) +# The old name, kept so an import written against it still resolves. InstrumentSpec = MarketSpec -"""The old name, kept so an import written against it still resolves.""" def precision_for(market: Market) -> Precision: @@ -114,6 +115,20 @@ def precision_for(market: Market) -> Precision: ) +def venue_precision(venue: str, *, tick: Decimal | None = None) -> Precision: + """A venue's order rules with no market loaded, from `VENUE_RULES`: for + sizing an amount on a combo the catalog has not read yet. `KeyError` for + a venue with no rules, rather than another venue's.""" + rules = VENUE_RULES[venue] + return Precision( + tick=tick or rules["default_tick"], + min_quantity=rules["min_quantity"], + quantity_step=rules["quantity_step"], + whole_contracts=rules["whole"], + min_notional=rules.get("min_notional"), + ) + + def spec_from_market(market: Market) -> MarketSpec: yes_info, no_info = market.yes.info or {}, market.no.info or {} tradable_yes = bool(yes_info.get("tradable", True)) # Polymarket US publishes it per side @@ -135,8 +150,8 @@ def spec_from_market(market: Market) -> MarketSpec: ) +# `(venue, market_ids) -> markets`, however the caller reaches the venue. Loader = Callable[[str, list[str]], Awaitable[list[Market]]] -"""`(venue, market_ids) -> markets`, however the caller reaches the venue.""" class MarketMaster: @@ -190,5 +205,5 @@ def __len__(self) -> int: return len(self._specs) +# The old name, kept so an import written against it still resolves. InstrumentMaster = MarketMaster -"""The old name, kept so an import written against it still resolves.""" diff --git a/synpath/trading/kalshi.py b/synpath/trading/kalshi.py index 8220f02..3102267 100644 --- a/synpath/trading/kalshi.py +++ b/synpath/trading/kalshi.py @@ -26,6 +26,7 @@ from __future__ import annotations import base64 +import logging import time import uuid from decimal import Decimal @@ -40,6 +41,7 @@ from .credentials import KalshiCredentials from .errors import ( DuplicateClientOrderId, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, + RfqNotFound, ) from .limiter import BudgetLimiter, Priority from .money import D, kalshi_count, kalshi_dollars, validate_quantity, validate_price @@ -47,8 +49,11 @@ Account, Balance, EditRequest, FeeEstimate, Fill, HeldBy, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, SettlementState, Side, TimeInForce, VENUE_ORDER_TYPES, + AcceptQuote, Combo, ComboLeg, MakerQuote, QuoteStatus, Rfq, RfqCloseReason, RfqQuote, RfqStatus, ) +log = logging.getLogger("synpath.trading.kalshi") + VENUE = "kalshi" BASE_URLS = { @@ -56,21 +61,24 @@ "prod": "https://external-api.kalshi.com/trade-api/v2", } +# `gtd` is `good_till_canceled` plus `expiration_time`. `day` is not here on +# purpose: the engine rewrites it to `gtd` at the session end before an +# adapter sees it, and an adapter that received one would have to invent a +# session. TIF_TO_VENUE = { TimeInForce.GTC: "good_till_canceled", TimeInForce.IOC: "immediate_or_cancel", TimeInForce.FOK: "fill_or_kill", TimeInForce.GTD: "good_till_canceled", } -"""`gtd` is `good_till_canceled` plus `expiration_time`. `day` is not here on -purpose: the engine rewrites it to `gtd` at the session end before an -adapter sees it, and an adapter that received one would have to invent a -session.""" +# Tokens a call costs unless the venue's endpoint-cost table says +# otherwise; one order is one default call, a batch is one per order. DEFAULT_COST = 10 -"""Tokens a call costs unless the venue's endpoint-cost table says -otherwise; one order is one default call, a batch is one per order.""" +# The venue's published non-default costs as of 2026-09-17, used until +# `fetch_limits` replaces them with the live table. Paths are the venue's +# own patterns: `:name` matches one segment, `*name` the rest. KNOWN_COSTS: dict[tuple[str, str], int] = { ("DELETE", "/trade-api/v2/portfolio/events/orders"): 2, ("DELETE", "/trade-api/v2/portfolio/events/orders/:order_id"): 2, @@ -81,16 +89,13 @@ ("GET", "/trade-api/v2/communications/rfqs/:rfq_id/quotes/:quote_id"): 2, ("PUT", "/trade-api/v2/communications/rfqs/:rfq_id/quotes/:quote_id/confirm"): 1, } -"""The venue's published non-default costs as of 2026-09-17, used until -`fetch_limits` replaces them with the live table. Paths are the venue's -own patterns: `:name` matches one segment, `*name` the rest.""" +# What an order is checked against when the caller gives no instrument +# spec: the coarse tick every market accepts, and the venue's two-decimal +# contract count. A market with a finer ladder wants its own `Precision`. DEFAULT_PRECISION = Precision( tick=Decimal("0.01"), min_quantity=Decimal("0.01"), quantity_step=Decimal("0.01"), whole_contracts=False, ) -"""What an order is checked against when the caller gives no instrument -spec: the coarse tick every market accepts, and the venue's two-decimal -contract count. A market with a finer ladder wants its own `Precision`.""" # --------------------------------------------------------------------------- @@ -202,7 +207,6 @@ def translate_order(request: OrderRequest, *, precision: Precision | None = None "time_in_force": tif, "self_trade_prevention_type": params.pop("self_trade_prevention_type", "taker_at_cross"), "post_only": request.post_only, - "reduce_only": request.reduce_only, } if request.time_in_force == TimeInForce.GTD and request.expires_at is not None: body["expiration_time"] = int(request.expires_at // 1000) @@ -240,6 +244,20 @@ def _side_of(raw: dict[str, Any]) -> Side: return Side.BUY if (action == "buy") == (side == "yes") else Side.SELL +def _execution_average(raw: dict[str, Any], *, filled: Decimal, cost: Decimal) -> Decimal | None: + if filled <= 0 or cost <= 0: + return None + # Read projections report cost in their priced outcome. The new API can + # say side=yes while outcome_side=no; only the latter identifies the cost + # leg. Create responses already supply a YES average and bypass this path. + outcome = str(raw.get("outcome_side") or raw.get("side") or + ("no" if raw.get("book_side") == "ask" else "yes")).lower() + average = cost / filled + if outcome == "no": + average = 1 - average + return average.quantize(Decimal("0.0001")) + + def order_of(raw: dict[str, Any], *, account: Account | None = None) -> Order: """A venue order row as an `Order`, on the YES leg.""" ticker = str(raw.get("ticker") or "") @@ -267,7 +285,7 @@ def order_of(raw: dict[str, Any], *, account: Account | None = None) -> Order: quantity=quantity, filled=filled, remaining=remaining, - average_price=(cost / filled).quantize(Decimal("0.0001")) if filled > 0 and cost > 0 else None, + average_price=_execution_average(raw, filled=filled, cost=cost), cost=cost if filled > 0 else None, fee=fee if filled > 0 else None, fee_currency="USD", @@ -314,7 +332,6 @@ def order_from_response( fee=D(raw["average_fee_paid"]) * filled if raw.get("average_fee_paid") not in (None, "") and filled > 0 else None, fee_currency="USD", post_only=bool(body.get("post_only")), - reduce_only=bool(body.get("reduce_only")), expires_at=int(body["expiration_time"]) * 1000 if body.get("expiration_time") else None, created_at=int(ts_ms) if ts_ms is not None else None, updated_at=int(ts_ms) if ts_ms is not None else None, @@ -423,6 +440,8 @@ def map_error(exc: ExchangeError, *, path: str = "") -> ExchangeError: if "insufficient" in text and ("balance" in text or "fund" in text): return InsufficientFunds(message, body=exc.body, status=exc.status) missing = "not_found" in text or "not found" in text or "no such" in text or isinstance(exc, MarketNotFound) + if missing and path.startswith(RFQ_PATHS): + return RfqNotFound(message, body=exc.body, status=exc.status) if missing and ("/orders" in path or "order" in text): return OrderNotFound(message, body=exc.body, status=exc.status) if "client_order_id" in text and ("duplicate" in text or "already" in text or "exists" in text): @@ -436,6 +455,288 @@ def map_error(exc: ExchangeError, *, path: str = "") -> ExchangeError: return exc +# --------------------------------------------------------------------------- +# RFQ and combos (docs/rfq-combo.md, P6) +# --------------------------------------------------------------------------- +# +# Source: https://docs.kalshi.com/openapi.yaml (v3.34.0) and +# https://docs.kalshi.com/getting_started/rfqs.md. +# +# **Sides.** A Kalshi quote is two bids for the RFQ's full size: `yes_bid` +# (the maker buys YES) and `no_bid` (the maker buys NO), either one "0" (the +# side declined), their sum at most $1. In YES prices the requester sells YES +# at `yes_bid` (our `bid`) and buys YES at `1 - no_bid` (our `ask`). +# `accepted_side` names the maker's bid being taken, not the requester's +# outcome, which the P0 demo run settled: to buy YES send `no` (the price is +# `1 - no_bid`), to buy NO send `yes` (the price is `1 - yes_bid`, i.e. YES +# sold at `yes_bid`). So a reported `accepted_side` of `no` is a YES buy by +# the requester and `yes` a YES sell. +# +# **Combos.** A combo is a market created in a multivariate event collection, +# with a `KXMVE...` ticker, its own book, positions and settlement: an +# ordinary binary market, so its Synpath id is `kalshi:`. Its legs +# are the market's `mve_selected_legs`. + +# Paths whose 404 names an RFQ, a quote or a combo collection. +RFQ_PATHS = ("/communications", "/multivariate_event_collections") + +# Every multivariate market's ticker starts so; the fallback when an RFQ +# carries no `mve_collection_ticker`. +COMBO_PREFIX = "KXMVE" + +# `POST /multivariate_event_collections/{t}`: creations per user per week. +WEEKLY_COMBO_QUOTA = 5000 + +QUOTE_STATUS_FROM_WIRE = { + "open": QuoteStatus.OPEN, + "accepted": QuoteStatus.ACCEPTED, + "confirmed": QuoteStatus.CONFIRMED, + "executed": QuoteStatus.EXECUTED, + # Withdrawn by the maker, closed with its RFQ, or lapsed in the last look: + # the venue has no expired or failed quote status, and none of these traded. + "cancelled": QuoteStatus.CANCELED, + "canceled": QuoteStatus.CANCELED, +} +QUOTE_STATUS_TO_WIRE = { + QuoteStatus.OPEN: "open", QuoteStatus.ACCEPTED: "accepted", QuoteStatus.CONFIRMED: "confirmed", + QuoteStatus.EXECUTED: "executed", QuoteStatus.CANCELED: "cancelled", +} + +# The requester's YES-leg side -> the maker's bid it takes (P0, settled). +ACCEPTED_SIDE_TO_WIRE = {Side.BUY: "no", Side.SELL: "yes"} +ACCEPTED_SIDE_FROM_WIRE = {wire: side for side, wire in ACCEPTED_SIDE_TO_WIRE.items()} + +# A combo market's lifecycle in `Combo.status`; every other state +# (initialized, inactive, closed, disputed) reads `closed`. +MARKET_STATUS_TO_COMBO = { + "active": "open", + "determined": "settled", + "finalized": "settled", + "amended": "settled", +} + + +def _dollars(value: Any) -> Decimal | None: + return D(str(value)) if value not in (None, "") else None + + +def _positive(value: Any) -> Decimal | None: + """A price or size, `None` for "0" (a declined side) or an absent one.""" + number = _dollars(value) + return number if number is not None and number > 0 else None + + +def is_combo_ticker(ticker: str) -> bool: + return ticker.upper().startswith(COMBO_PREFIX) + + +def leg_of(raw: dict[str, Any]) -> ComboLeg: + """One `MveSelectedLeg` (`event_ticker`, `market_ticker`, `side`).""" + side = str(raw.get("side") or "yes").lower() + return ComboLeg( + market_id=ids.qualify(VENUE, str(raw.get("market_ticker") or "")), + outcome="no" if side == "no" else "yes", + event_id=raw.get("event_ticker") or None, + info=raw, + ) + + +def leg_to_wire(leg: ComboLeg, event_ticker: str) -> dict[str, str]: + """A `TickerPair` for `selected_markets`.""" + return {"market_ticker": ticker_of(leg.market_id), "event_ticker": event_ticker, "side": leg.outcome} + + +def with_event_tickers(collection: dict[str, Any]) -> dict[str, Any]: + """A collection row with `associated_event_tickers` always present: the + venue sends it beside `associated_events`, and it is filled from those + when it is missing.""" + row = dict(collection) + if not row.get("associated_event_tickers"): + row["associated_event_tickers"] = [str(e.get("ticker")) for e in row.get("associated_events") or [] + if e.get("ticker")] + return row + + +def combo_of(market: dict[str, Any]) -> Combo: + """A multivariate market (`GET /markets/{ticker}`, or the `market` of the + create answer) as a `Combo`; its legs are `mve_selected_legs`.""" + ticker = str(market.get("ticker") or "") + legs = market.get("mve_selected_legs") or [] + if len(legs) < 2: + raise RfqNotFound(f"kalshi: {ticker or 'the market'} is not a combo (no selected legs)") + status = str(market.get("status") or "").lower() + return Combo( + market_id=ids.qualify(VENUE, ticker), + venue=VENUE, + collection_id=market.get("mve_collection_ticker") or None, + legs=[leg_of(leg) for leg in legs], + status=MARKET_STATUS_TO_COMBO.get(status, "closed") if status else None, + created_at=parse_ts(market.get("created_time")), + info=market, + ) + + +def _closed_reason(raw: dict[str, Any]) -> RfqCloseReason | None: + """`cancellation_reason` is free text on the venue; read what it plainly says.""" + if str(raw.get("status") or "").lower() != "closed": + return None + text = str(raw.get("cancellation_reason") or "").lower() + if "expire" in text: + return RfqCloseReason.EXPIRED + if "execut" in text or "fill" in text: + return RfqCloseReason.EXECUTED + if "replace" in text: + return RfqCloseReason.REPLACED + if "cancel" in text or "delete" in text or "user" in text: + return RfqCloseReason.CANCELED + return None + + +def rfq_of(raw: dict[str, Any], *, me: str | None = None, mine: bool | None = None, + account: Account | None = None) -> Rfq: + """A venue `RFQ`. Sized by `target_cost_dollars` when it has one (the + venue still reports a `contracts_fp`, kept in `info`), else by + `contracts_fp`. `mine` is whether `creator_id` is `me`, this account's + communications id, unless the caller already knows.""" + ticker = str(raw.get("market_ticker") or "") + instrument = ids.qualify(VENUE, ticker) + legs = raw.get("mve_selected_legs") or [] + combo = None + if len(legs) >= 2: + combo = Combo(market_id=instrument, venue=VENUE, collection_id=raw.get("mve_collection_ticker") or None, + legs=[leg_of(leg) for leg in legs], info={"mve_selected_legs": legs}) + on_combo = combo is not None or bool(raw.get("mve_collection_ticker")) or is_combo_ticker(ticker) + if mine is None: + mine = me is not None and raw.get("creator_id") == me + amount = _positive(raw.get("target_cost_dollars")) + status = RfqStatus.CLOSED if str(raw.get("status") or "").lower() == "closed" else RfqStatus.OPEN + return Rfq( + id=str(raw.get("id") or ""), + venue=VENUE, + account=account if mine else None, + market_id=None if on_combo else instrument, + combo_id=instrument if on_combo else None, + combo=combo, + quantity=None if amount is not None else _positive(raw.get("contracts_fp")), + amount=amount, + rest_remainder=bool(raw.get("rest_remainder")), + status=status, + closed_reason=_closed_reason(raw), + mine=mine, + created_at=parse_ts(raw.get("created_ts")), + updated_at=parse_ts(raw.get("updated_ts") or raw.get("cancelled_ts")), + info=raw, + ) + + +def quote_of(raw: dict[str, Any], *, me: str | None = None, mine: bool | None = None, + account: Account | None = None) -> RfqQuote: + """A venue `Quote` on the YES leg: `bid` = `yes_bid_dollars`, `ask` = + `1 - no_bid_dollars`, a "0" bid a declined side (`None`). + + Sizes: `yes_contracts_fp` is read as the YES contracts the requester can + buy (the `ask` side) and `no_contracts_fp` as the NO contracts it can buy + (the `bid` side); both fall back to `contracts_fp`. They differ only on an + RFQ sized by target cost (UNVERIFIED which is which; P10 checks).""" + if mine is None: + mine = me is not None and raw.get("creator_id") == me + yes_bid = _positive(raw.get("yes_bid_dollars")) + no_bid = _positive(raw.get("no_bid_dollars")) + full = _positive(raw.get("contracts_fp")) + ticker = raw.get("market_ticker") + quote = RfqQuote.from_venue(dict( + id=str(raw.get("id") or ""), + rfq_id=str(raw.get("rfq_id") or ""), + venue=VENUE, + account=account if mine else None, + market_id=ids.qualify(VENUE, str(ticker)) if ticker else None, + bid=yes_bid, + ask=(1 - no_bid) if no_bid is not None else None, + bid_quantity=(_positive(raw.get("no_contracts_fp")) or full) if yes_bid is not None else None, + ask_quantity=(_positive(raw.get("yes_contracts_fp")) or full) if no_bid is not None else None, + status=QUOTE_STATUS_FROM_WIRE.get(str(raw.get("status") or "").lower(), QuoteStatus.OPEN), + accepted_side=ACCEPTED_SIDE_FROM_WIRE.get(str(raw.get("accepted_side") or "").lower()), + taker_order_id=raw.get("rfq_creator_order_id") or None, + maker_order_id=raw.get("creator_order_id") or None, + mine=mine, + created_at=parse_ts(raw.get("created_ts")), + updated_at=parse_ts(raw.get("updated_ts")), + info=raw, + )) + if quote.crossed: + log.warning("kalshi: quote %s on RFQ %s is crossed (yes_bid %s above 1 - no_bid %s); kept and flagged", + quote.id, quote.rfq_id, quote.bid, quote.ask) + return quote + + +def tolerant(rows: Any, build: Any, what: str) -> list[Any]: + """Normalise a venue list row by row: a row the model refuses is logged + and skipped, so one bad record does not break the whole list.""" + out = [] + for row in rows or []: + try: + out.append(build(row)) + except (ValueError, TypeError) as exc: + log.warning("kalshi: skipped a %s the venue sent that does not validate (%s): %r", what, exc, row) + return out + + +def translate_rfq( + instrument_id: str, *, quantity: Decimal | None, amount: Decimal | None, rest_remainder: bool, + target_cost_excludes_fees: bool = False, replace_existing: bool = False, subaccount: int | None = None, +) -> dict[str, Any]: + """The `POST /communications/rfqs` body: `contracts_fp` for contracts + (0.01 steps), `target_cost_dollars` for money. By default the target cost + caps principal plus Kalshi's fees and the venue shrinks each quote's size + to make room for them; `target_cost_excludes_fees=True` makes it principal + only, with the taker fee charged on top.""" + if (quantity is None) == (amount is None): + raise InvalidOrder("kalshi: size an RFQ by quantity (contracts) or amount (USD), exactly one") + body: dict[str, Any] = {"market_ticker": ticker_of(instrument_id), "rest_remainder": bool(rest_remainder)} + if quantity is not None: + body["contracts_fp"] = kalshi_count(validate_quantity(D(quantity), DEFAULT_PRECISION)) + else: + if D(amount) <= 0: # type: ignore[arg-type] + raise InvalidOrder(f"kalshi: amount must be positive, got {amount}") + body["target_cost_dollars"] = kalshi_dollars(D(amount)) # type: ignore[arg-type] + if target_cost_excludes_fees: + body["target_cost_excludes_fees"] = True + if target_cost_excludes_fees and amount is None: + raise InvalidOrder("kalshi: target_cost_excludes_fees applies only to an RFQ sized by amount") + if replace_existing: + body["replace_existing"] = True + if subaccount is not None: + body["subaccount"] = int(subaccount) + return body + + +def translate_quote(quote: MakerQuote, *, subaccount: int | None = None) -> dict[str, Any]: + """The `POST /communications/quotes` body. Our `bid` (the YES price we + buy at) is `yes_bid`; our `ask` (the YES price we sell at) is buying NO + at `1 - ask`, so `no_bid = 1 - ask`. A missing side is "0". The venue + takes no quantity, expiry or client id: a quote is for the RFQ's size.""" + if quote.quantity is not None: + raise InvalidOrder("kalshi: a quote is for the RFQ's full size; the venue takes no quantity") + if quote.expires_at is not None: + raise InvalidOrder("kalshi: a quote has no expiry on this venue; cancel it instead") + zero = kalshi_dollars(Decimal("0")) + body: dict[str, Any] = { + "rfq_id": quote.rfq_id, + "yes_bid": kalshi_dollars(quote.bid) if quote.bid is not None else zero, + "no_bid": kalshi_dollars(1 - quote.ask) if quote.ask is not None else zero, + "rest_remainder": bool(quote.rest_remainder), + "post_only": bool(quote.post_only), + } + if subaccount is not None: + body["subaccount"] = int(subaccount) + return body + + +def accept_key(quote_id: str) -> str: + """The engine's idempotency key for an accept (docs/rfq-combo.md §5).""" + return f"rfq-accept:{VENUE}:{quote_id}" + + # --------------------------------------------------------------------------- # Adapter # --------------------------------------------------------------------------- @@ -471,7 +772,13 @@ class KalshiTrading(TradingExchange): "fetch_settlements": True, "fetch_queue_position": True, "fetch_fee_estimate": True, + # The older key, kept for one release: the adapter has Kalshi's RFQ + # methods. Since P6 they are the typed shared interface below. "rfq": True, + # docs/rfq-combo.md (P6): both RFQ sides and the multivariate combos. + "rfq_taker": True, + "rfq_quoter": True, + "combos": True, # Kalshi nets YES against NO in one account; there are no token # inventories to split or merge. "split_merge": False, @@ -504,6 +811,7 @@ def __init__( self.http = AsyncHttpClient(self.base_url, limiter=None, timeout=timeout, client=client, venue=VENUE) self.default_cost = DEFAULT_COST self.endpoint_costs: dict[tuple[str, str], int] = dict(KNOWN_COSTS) + self._communications_id: str | None = None # -- transport ------------------------------------------------------------ @@ -552,6 +860,9 @@ async def create_order(self, request: OrderRequest, *, precision: Precision | No order = order_from_response(raw, body=body, account=request.account or self.account) return order.model_copy(update={"book": request.book, "trader": request.trader, "tags": request.tags}) + async def create_order_with_precision(self, request: OrderRequest, precision: Precision) -> Order: + return await self.create_order(request, precision=precision) + async def create_orders( self, requests: list[OrderRequest], *, precision: Precision | None = None, ) -> list[Order | Exception]: @@ -895,45 +1206,364 @@ async def fetch_order_group(self, order_group_id: str) -> dict[str, Any]: async def delete_order_group(self, order_group_id: str) -> None: await self._call("DELETE", f"/portfolio/order_groups/{order_group_id}", kind="write", priority=Priority.HIGH) - # -- RFQ ------------------------------------------------------------------ + # -- combos (multivariate event collections) ------------------------------- + + async def fetch_combo_collections( + self, *, status: str | None = "open", event_id: str | None = None, series_id: str | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[dict[str, Any]]: + """`GET /multivariate_event_collections`: the collections a combo can be + built in, as the venue's own `MultivariateEventCollection` objects + (`collection_ticker`, `associated_events` with `active_quoters`, + `size_min`/`size_max`, `price_ranges`...). `status` is `unopened`, + `open` or `closed`; `event_id` keeps those that take that event.""" + raw = await self._call("GET", "/multivariate_event_collections", params={ + "status": status, "associated_event_ticker": ids.native(VENUE, event_id) if event_id else None, + "series_ticker": series_id, "limit": limit, "cursor": cursor, + }) + rows = [with_event_tickers(row) for row in (raw or {}).get("multivariate_contracts") or []] + return Page(rows, next_cursor=(raw or {}).get("cursor") or None) + + async def fetch_combo_collection(self, collection_id: str) -> dict[str, Any]: + raw = await self._call("GET", f"/multivariate_event_collections/{collection_id}") + found = (raw or {}).get("multivariate_contract") + if not found: + raise RfqNotFound(f"kalshi: no collection {collection_id!r}") + return with_event_tickers(found) + + async def _event_of(self, leg: ComboLeg) -> str: + """A leg's event ticker: `event_id` when given, else the market's own.""" + if leg.event_id: + return ids.native(VENUE, leg.event_id) + raw = await self._call("GET", f"/markets/{ticker_of(leg.market_id)}") + event = ((raw or {}).get("market") or {}).get("event_ticker") + if not event: + raise InvalidOrder(f"kalshi: {leg.market_id} names no event; give the leg's event_id") + return str(event) + + async def _collection_for(self, events: list[str], size: int) -> str: + """The one open collection that takes every leg's event at this size.""" + fits: list[str] = [] + cursor: str | None = None + while True: + page = await self.fetch_combo_collections(event_id=events[0], limit=200, cursor=cursor) + for row in page: + held = {str(e.get("ticker")) for e in row.get("associated_events") or []} \ + or set(row.get("associated_event_tickers") or []) + low, high = row.get("size_min"), row.get("size_max") + if set(events) <= held and (low is None or size >= int(low)) and (high is None or size <= int(high)): + fits.append(str(row.get("collection_ticker"))) + cursor = page.next_cursor + if not cursor or not page: + break + if len(fits) == 1: + return fits[0] + if not fits: + raise InvalidOrder(f"kalshi: no open collection takes events {', '.join(events)} as a {size}-leg combo") + exc = InvalidOrder( + f"kalshi: {len(fits)} open collections take these legs ({', '.join(fits)}); name one with " + "collection_id" + ) + exc.collections = fits # type: ignore[attr-defined] # served as `details.collections` + raise exc + + async def create_combo(self, legs: list[ComboLeg], *, collection_id: str | None = None) -> Combo: + """`POST /multivariate_event_collections/{collection}` with + `selected_markets`: the combo market for these legs, created if it does + not exist (the venue returns the same market for the same legs). It + must be called once before a combo can be traded or read. Signed, and + capped at 5,000 creations a user a week (`WEEKLY_COMBO_QUOTA`). + + `collection_id` is the collection's ticker (`KXMVE...`); without it the + one open collection that takes every leg's event is used, and more + than one is refused. A leg without `event_id` has its market read for + it. Legs the collection refuses are `InvalidOrder`.""" + if len(legs) < 2: + raise InvalidOrder(f"kalshi: a combo has at least two legs, got {len(legs)}") + if len({ticker_of(leg.market_id) for leg in legs}) != len(legs): + raise InvalidOrder("kalshi: a combo names each market once") + events = [await self._event_of(leg) for leg in legs] + collection = collection_id or await self._collection_for(list(dict.fromkeys(events)), len(legs)) + body = {"selected_markets": [leg_to_wire(leg, event) for leg, event in zip(legs, events)], + "with_market_payload": True} + try: + raw = await self._call("POST", f"/multivariate_event_collections/{collection}", json=body, kind="write") + except OrderRejected as exc: + raise InvalidOrder(str(exc), body=exc.body, status=exc.status) from None + ticker = str((raw or {}).get("market_ticker") or "") + if not ticker: + raise ExchangeError("kalshi: create combo returned no market", body=raw) + market = (raw or {}).get("market") or {} + if market.get("mve_selected_legs"): + combo = combo_of(market) + else: + combo = Combo( + market_id=ids.qualify(VENUE, ticker), venue=VENUE, collection_id=collection, + legs=[leg.model_copy(update={"event_id": event}) for leg, event in zip(legs, events)], + info={"response": raw}, + ) + return combo.model_copy(update={"collection_id": combo.collection_id or collection, + "info": {**combo.info, "event_ticker": raw.get("event_ticker")}}) + + async def fetch_combo(self, combo_id: str) -> Combo: + """`GET /markets/{ticker}` on a combo's market ticker; legs from its + `mve_selected_legs`. A market that is not a combo is `RfqNotFound`.""" + ticker = ticker_of(combo_id) + try: + raw = await self._call("GET", f"/markets/{ticker}") + except MarketNotFound as exc: + raise RfqNotFound(f"kalshi: no combo {ticker!r}", body=exc.body, status=exc.status) from None + return combo_of((raw or {}).get("market") or {"ticker": ticker}) + + async def lookup_combo(self, legs: list[ComboLeg]) -> Combo | None: + raise NotSupported( + "kalshi: no read-only lookup by legs (the lookup endpoint left the API); create_combo returns the " + "existing combo market for the same legs" + ) + + # -- RFQ, both sides -------------------------------------------------------- + + async def fetch_communications_id(self) -> str: + """This account's public communications id (`GET /communications/id`), + which marks its own RFQs (`creator_id`) and quotes. Read once and kept.""" + if self._communications_id is None: + raw = await self._call("GET", "/communications/id") + self._communications_id = str((raw or {}).get("communications_id") or "") or None + return self._communications_id or "" + + async def _rfq_page(self, params: dict[str, Any], *, mine: bool | None) -> Page[Rfq]: + me = None if mine is not None else await self.fetch_communications_id() + raw = await self._call("GET", "/communications/rfqs", params=params) + rows = tolerant((raw or {}).get("rfqs"), lambda row: rfq_of(row, me=me, mine=mine, account=self.account), "RFQ") + return Page(rows, next_cursor=(raw or {}).get("cursor") or None) + + async def _quote_list(self, params: dict[str, Any], *, mine: bool | None = None) -> list[RfqQuote]: + me = None if mine is not None else await self.fetch_communications_id() + quotes: list[RfqQuote] = [] + cursor: str | None = None + while True: + raw = await self._call("GET", "/communications/quotes", params={**params, "cursor": cursor}) + quotes.extend(tolerant((raw or {}).get("quotes"), + lambda row: quote_of(row, me=me, mine=mine, account=self.account), "quote")) + cursor = (raw or {}).get("cursor") or None + if not cursor: + return quotes + + @staticmethod + def _quote_status(status: QuoteStatus | str | None) -> str | None: + if status is None: + return None + wire = QUOTE_STATUS_TO_WIRE.get(QuoteStatus(status)) + if wire is None: + raise BadRequest(f"kalshi: quotes have no {QuoteStatus(status).value!r} status on this venue") + return wire + + def _subaccount(self, account: Account | None = None) -> int | None: + target = account or self.account + return int(target.subaccount) if target.subaccount is not None else None + + # -- RFQ, requester (taker) side -------------------------------------------- async def create_rfq( - self, market_id: str, contracts: Decimal | int, *, rest_remainder: bool = False, - target_cost: Decimal | None = None, - ) -> str: - """Ask market makers for a two-sided quote on `contracts` of a market. - `rest_remainder` leaves what a quote does not fill resting on the - book. Returns the RFQ id; quotes arrive through `fetch_quotes`.""" - body: dict[str, Any] = { - "market_ticker": ticker_of(market_id), "contracts_fp": kalshi_count(D(contracts)), "rest_remainder": rest_remainder, - } - if target_cost is not None: - body["target_cost_dollars"] = kalshi_dollars(D(target_cost)) + self, instrument_id: str, *, quantity: Decimal | None = None, amount: Decimal | None = None, + outcome: str | None = None, action: str | None = None, rest_remainder: bool = False, + combo: Combo | None = None, target_cost_excludes_fees: bool = False, replace_existing: bool = False, + ) -> Rfq: + """`POST /communications/rfqs` on a market or a combo market. Two-sided: + the venue takes no side, so `outcome`/`action` are refused. `quantity` + is sent as `contracts_fp`; `amount` (USD) as `target_cost_dollars`, + which by default caps principal plus fees (quote sizes shrink to fit + the fees); `target_cost_excludes_fees=True` makes it principal only. + At most 100 open RFQs and one per market (409 otherwise, unless + `replace_existing`). An unanswered RFQ expires on the venue. + Returns the RFQ as sent, `open`; the venue answers with its id.""" + if outcome is not None or action is not None: + raise InvalidOrder("kalshi: an RFQ is two-sided; pick the side when accepting a quote") + body = translate_rfq(instrument_id, quantity=quantity, amount=amount, rest_remainder=rest_remainder, + target_cost_excludes_fees=target_cost_excludes_fees, replace_existing=replace_existing, + subaccount=self._subaccount()) raw = await self._call("POST", "/communications/rfqs", json=body, kind="write") - return str((raw or {}).get("id") or "") + rfq_id = str((raw or {}).get("id") or "") + if not rfq_id: + raise ExchangeError("kalshi: create RFQ returned no id", body=raw) + instrument = ids.qualify(VENUE, body["market_ticker"]) + on_combo = combo is not None or is_combo_ticker(body["market_ticker"]) + return Rfq( + id=rfq_id, venue=VENUE, account=self.account, + market_id=None if on_combo else instrument, combo_id=instrument if on_combo else None, combo=combo, + quantity=D(body["contracts_fp"]) if "contracts_fp" in body else None, + amount=D(amount) if amount is not None else None, + rest_remainder=rest_remainder, status=RfqStatus.OPEN, mine=True, created_at=int(time.time() * 1000), + info={"request": body, "response": raw}, + ) - async def fetch_rfq(self, rfq_id: str) -> dict[str, Any]: + async def fetch_rfq(self, rfq_id: str) -> Rfq: + me = await self.fetch_communications_id() raw = await self._call("GET", f"/communications/rfqs/{rfq_id}") - return dict((raw or {}).get("rfq") or {}) + found = (raw or {}).get("rfq") + if not found: + raise RfqNotFound(f"kalshi: no RFQ {rfq_id}") + return rfq_of(found, me=me, account=self.account) + + async def fetch_rfqs( + self, *, status: RfqStatus | str | None = None, market_id: str | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Rfq]: + """This account's RFQs (`user_filter=self`).""" + return await self._rfq_page({ + "user_filter": "self", "status": RfqStatus(status).value if status else None, + "market_ticker": ticker_of(market_id) if market_id else None, + "subaccount": self._subaccount(), "limit": limit, "cursor": cursor, + }, mine=True) + + async def cancel_rfq(self, rfq_id: str) -> None: + """`DELETE /communications/rfqs/{id}`.""" + await self._call("DELETE", f"/communications/rfqs/{rfq_id}", kind="write", priority=Priority.HIGH) - async def fetch_quotes(self, rfq_id: str, *, status: str | None = "open") -> list[dict[str, Any]]: - """Quotes answering one of this account's RFQs. The venue filters by - the asking account, not by RFQ, so the RFQ is picked out here.""" - raw = await self._call("GET", "/communications/quotes", params={ - "rfq_user_filter": "self", "status": status, "limit": 500, + async def fetch_quotes(self, rfq_id: str, *, status: QuoteStatus | str | None = None) -> list[RfqQuote]: + """The makers' quotes on one of our RFQs: `rfq_user_filter=self` and + `rfq_id`. Rows for another RFQ are dropped here as well.""" + rows = await self._quote_list({ + "rfq_user_filter": "self", "rfq_id": rfq_id, "status": self._quote_status(status), "limit": 500, + }) + return [q for q in rows if q.rfq_id == rfq_id] + + async def fetch_quote(self, quote_id: str, *, rfq_id: str | None = None) -> RfqQuote: + """`GET /communications/rfqs/{rfq}/quotes/{q}`.""" + if not rfq_id: + raise InvalidOrder("kalshi: a quote is read under its RFQ; give rfq_id") + me = await self.fetch_communications_id() + raw = await self._call("GET", f"/communications/rfqs/{rfq_id}/quotes/{quote_id}") + found = (raw or {}).get("quote") + if not found: + raise RfqNotFound(f"kalshi: no quote {quote_id} on RFQ {rfq_id}") + return quote_of(found, me=me, account=self.account) + + async def accept_quote(self, accept: AcceptQuote) -> RfqQuote: + """`PUT /communications/rfqs/{rfq}/quotes/{q}/accept {accepted_side}`. + + `accepted_side` names the maker's bid taken (P0, settled on the demo): + buying YES (or selling NO) sends `no`, at `1 - no_bid` (the quote's + `ask`); buying NO (or selling YES) sends `yes`, at `1 - yes_bid` a NO + contract, i.e. YES sold at `yes_bid` (the `bid`). + + The quote is read first. Refused before anything is sent when it is + not open, the side was declined (its bid "0"), or the price is worse + than `worst_price`. REST accepts the full size only (`quantity` and + `amount` are refused; FIX takes a partial size). A retry is safe: a + quote already accepted on the same side is returned without sending + again. The venue takes no client id; the idempotency key defaults to + `rfq-accept:kalshi:{quote_id}` and is kept in `info`. Returns at once + with the quote `accepted`; the maker then confirms (30 s, 3 s on combo + and high-volume markets) and the orders follow (`taker_order_id` is + `rfq_creator_order_id`; its fills are in `fetch_my_trades`).""" + if not accept.rfq_id: + raise InvalidOrder("kalshi: a quote is accepted under its RFQ; give rfq_id") + if accept.quantity is not None or accept.amount is not None: + raise InvalidOrder("kalshi: a quote is accepted for its full size over REST; the venue takes no partial size") + side = accept.side + wire_side = ACCEPTED_SIDE_TO_WIRE[side] + key = accept.client_order_id or accept_key(accept.quote_id) + quote = await self.fetch_quote(accept.quote_id, rfq_id=accept.rfq_id) + if quote.status in (QuoteStatus.ACCEPTED, QuoteStatus.CONFIRMED, QuoteStatus.EXECUTED): + if quote.accepted_side == side: + return quote.model_copy(update={"info": {**quote.info, "accept": { + "accepted_side": wire_side, "client_order_id": key, "resent": False}}}) + raise OrderRejected( + f"kalshi: quote {quote.id} was already accepted on the {quote.accepted_side} side", + reason="already_accepted", info=quote.info, + ) + if quote.status != QuoteStatus.OPEN: + raise OrderRejected(f"kalshi: quote {quote.id} is {quote.info.get('status') or quote.status.value}, not open", + reason="quote_not_open", info=quote.info) + price = quote.price_for(accept.outcome, accept.action) + if price is None: + declined = "no_bid" if side == Side.BUY else "yes_bid" + raise InvalidOrder(f"kalshi: quote {quote.id} declines the {side.value} side ({declined} is 0)") + if accept.refuses(price): + raise InvalidOrder( + f"kalshi: quote {quote.id} {side.value}s YES at {price}, worse than the worst price {accept.worst_price}" + ) + body = {"accepted_side": wire_side} + raw = await self._call( + "PUT", f"/communications/rfqs/{accept.rfq_id}/quotes/{accept.quote_id}/accept", json=body, + kind="write", priority=Priority.HIGH, + ) + return quote.model_copy(update={ + "status": QuoteStatus.ACCEPTED, "accepted_side": side, + "info": {**quote.info, "accept": {**body, "client_order_id": key, "response": raw, "resent": True}}, }) - return [q for q in ((raw or {}).get("quotes") or []) if str(q.get("rfq_id")) == rfq_id] - async def accept_quote(self, rfq_id: str, quote_id: str, side: str) -> None: - """Take one side (`yes` or `no`) of a quote. The quoter then has a - last look and confirms; the trade prints on their confirmation.""" - await self._call( - "PUT", f"/communications/rfqs/{rfq_id}/quotes/{quote_id}/accept", - json={"accepted_side": side}, kind="write", + # -- RFQ, quoter (maker) side ----------------------------------------------- + + async def fetch_incoming_rfqs( + self, *, market_id: str | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Rfq]: + """Open RFQs from other accounts (ours are left out).""" + page = await self._rfq_page({ + "status": "open", "market_ticker": ticker_of(market_id) if market_id else None, + "limit": limit, "cursor": cursor, + }, mine=None) + return Page([r for r in page if not r.mine], next_cursor=page.next_cursor) + + async def send_quote(self, quote: MakerQuote) -> RfqQuote: + """`POST /communications/quotes`, both sides at once: `yes_bid` = our + `bid`, `no_bid` = `1 - ask`, a missing side "0", with `rest_remainder` + and `post_only`. The venue holds collateral for a quote when it is + sent (a demo quote with too little balance is a 400 + `insufficient_balance`, `InsufficientFunds`). Returns the quote as + sent, `open`, under the venue's new id.""" + body = translate_quote(quote, subaccount=self._subaccount(quote.account)) + raw = await self._call("POST", "/communications/quotes", json=body, kind="write") + quote_id = str((raw or {}).get("id") or "") + if not quote_id: + raise ExchangeError("kalshi: create quote returned no id", body=raw) + return RfqQuote( + id=quote_id, rfq_id=quote.rfq_id, venue=VENUE, account=quote.account or self.account, + bid=quote.bid, ask=quote.ask, status=QuoteStatus.OPEN, mine=True, created_at=int(time.time() * 1000), + info={"request": body, "response": raw, "client_order_id": quote.client_order_id}, ) - async def delete_rfq(self, rfq_id: str) -> None: - await self._call("DELETE", f"/communications/rfqs/{rfq_id}", kind="write", priority=Priority.HIGH) + async def replace_quote(self, quote_id: str, quote: MakerQuote) -> RfqQuote: + """Kalshi has no replace: the old quote is deleted, then the new one is + sent. Between the two the RFQ has no quote of ours (one round trip). + An old quote already gone is not an error; if the send then fails, + nothing of ours is left on the RFQ.""" + try: + await self.cancel_quote(quote_id, rfq_id=quote.rfq_id) + except RfqNotFound: + pass + new = await self.send_quote(quote) + return new.model_copy(update={"info": {**new.info, "replaces": quote_id}}) + + async def cancel_quote(self, quote_id: str, *, rfq_id: str | None = None) -> None: + """`DELETE /communications/rfqs/{rfq}/quotes/{q}`: withdraw a quote, so + it can no longer be accepted (or decline an accepted one before the + confirm).""" + if not rfq_id: + raise InvalidOrder("kalshi: a quote is deleted under its RFQ; give rfq_id") + await self._call("DELETE", f"/communications/rfqs/{rfq_id}/quotes/{quote_id}", kind="write", + priority=Priority.HIGH) + + async def confirm_quote(self, quote: RfqQuote) -> RfqQuote: + """`PUT /communications/rfqs/{rfq}/quotes/{q}/confirm`, the maker's last + look: within 30 s of the accept (3 s on combo and high-volume + markets). The venue then starts the execution timer and the two + orders reach the book after 15 s (1 s on high-volume markets).""" + raw = await self._call( + "PUT", f"/communications/rfqs/{quote.rfq_id}/quotes/{quote.id}/confirm", json={}, + kind="write", priority=Priority.HIGH, + ) + return quote.model_copy(update={"status": QuoteStatus.CONFIRMED, "info": {**quote.info, "confirm": raw}}) + + async def fetch_my_quotes( + self, *, rfq_id: str | None = None, status: QuoteStatus | str | None = None, + ) -> list[RfqQuote]: + """Quotes this account made (`user_filter=self`, the spec's filter for + "quotes created by the authenticated user"), on one RFQ or all.""" + return await self._quote_list({ + "user_filter": "self", "rfq_id": rfq_id, "status": self._quote_status(status), "limit": 500, + }, mine=True) async def close(self) -> None: await self.http.close() diff --git a/synpath/trading/limiter.py b/synpath/trading/limiter.py index 72ae212..60d4553 100644 --- a/synpath/trading/limiter.py +++ b/synpath/trading/limiter.py @@ -42,8 +42,8 @@ class Priority(IntEnum): + # Cancels, kill switch, the replace half of an edit. HIGH = 0 - """Cancels, kill switch, the replace half of an edit.""" NORMAL = 1 @@ -61,12 +61,12 @@ def refill(self, now: float) -> None: @dataclass class _Lane: + # The venue's budget, as reserved by every caller. main: _Bucket - """The venue's budget, as reserved by every caller.""" + # What a high-priority call may borrow ahead of the queue. fast: _Bucket - """What a high-priority call may borrow ahead of the queue.""" + # Seconds of delay injected ahead of sleeping normal callers. shift: float = 0.0 - """Seconds of delay injected ahead of sleeping normal callers.""" @dataclass(frozen=True) diff --git a/synpath/trading/limitless.py b/synpath/trading/limitless.py index ecfefe2..c51b406 100644 --- a/synpath/trading/limitless.py +++ b/synpath/trading/limitless.py @@ -11,9 +11,9 @@ wallet rejects self-signed orders until it is switched back (`LimitlessTrading.use_eoa_trading_mode()`). -**YES and NO are separate tokens.** `buy` buys the YES token at the price -given; `sell` buys the NO token at `1 - price`; with `reduce_only`, `sell` -sells YES held and `buy` sells NO held. Everything the venue reports on the +**YES and NO are separate tokens.** An order buys or sells the token its +`outcome` and `action` name (with neither, `buy` buys YES and `sell` buys +NO at `1 - price`). Everything the venue reports on the NO token comes back on the YES leg. **Limits rest; market orders take.** A limit is `GTC`, post-only on request. @@ -74,12 +74,12 @@ API_URL = "https://api.limitless.exchange" RPC_URL = "https://mainnet.base.org" +# An order row's status. `UNMATCHED` is an immediate order that matched nothing. ROW_STATUS = {"LIVE": OrderStatus.OPEN, "MATCHED": OrderStatus.CLOSED, "CANCELED": OrderStatus.CANCELED, "UNMATCHED": OrderStatus.CANCELED} -"""An order row's status. `UNMATCHED` is an immediate order that matched nothing.""" +# Order ids per batch cancel or status lookup: the venue's maximum. BATCH = 50 -"""Order ids per batch cancel or status lookup: the venue's maximum.""" FILL_SETTLEMENT = { "MATCHED": SettlementState.MATCHED, @@ -87,12 +87,12 @@ "FAILED": SettlementState.FAILED, } +# An immediate order's terminal result: a partial fill's rest is cancelled. EXECUTION_STATUS = { "FILLED": OrderStatus.CLOSED, "PARTIALLY_FILLED": OrderStatus.CANCELED, "KILLED": OrderStatus.CANCELED, } -"""An immediate order's terminal result: a partial fill's rest is cancelled.""" def D(value: Any) -> Decimal: @@ -197,8 +197,8 @@ class MarketInfo: yes_token: str no_token: str exchange: str + # Whether the market charges fees; a free market's orders carry a zero rate. fee: bool - """Whether the market charges fees; a free market's orders carry a zero rate.""" @property def market_id(self) -> str: @@ -450,6 +450,10 @@ class LimitlessTrading(TradingExchange): "fetch_queue_position": False, "fetch_fee_estimate": True, "rfq": False, + # Typed RFQ and combos arrive phase by phase (docs/rfq-combo.md). + "rfq_taker": False, + "rfq_quoter": False, + "combos": False, "split_merge": False, "watch_orders": False, "watch_my_trades": False, @@ -485,8 +489,8 @@ def __init__( from ..limitless import Limitless catalog = Limitless() + # The read adapter: tokens, exchange and fee flag for a market. self.catalog = catalog - """The read adapter: tokens, exchange and fee flag for a market.""" self._markets: dict[str, MarketInfo] = {} self._profile: dict[str, Any] | None = None @@ -585,7 +589,8 @@ async def create_order(self, request: OrderRequest) -> Order: quantity=contracts, filled=filled, remaining=max(Decimal("0"), contracts - filled) if status == OrderStatus.OPEN else Decimal("0"), - reduce_only=request.reduce_only, + outcome=request.leg[0], + action=request.leg[1], post_only=request.post_only, created_at=int(time.time() * 1000), book=request.book, diff --git a/synpath/trading/limitless_signing.py b/synpath/trading/limitless_signing.py index 9a76ee0..694672f 100644 --- a/synpath/trading/limitless_signing.py +++ b/synpath/trading/limitless_signing.py @@ -39,8 +39,8 @@ EOA = 0 MIN_PRICE, MAX_PRICE = Decimal("0.01"), Decimal("0.99") +# The venue's dust floor on `makerAmount`, in raw units. MIN_MAKER_UNITS = 100 -"""The venue's dust floor on `makerAmount`, in raw units.""" def contracts_units(contracts: Decimal) -> int: diff --git a/synpath/trading/money.py b/synpath/trading/money.py index f58a5a4..c47f18e 100644 --- a/synpath/trading/money.py +++ b/synpath/trading/money.py @@ -114,6 +114,72 @@ def validate_quantity(quantity: Decimal, precision: Precision) -> Decimal: return quantity +# -- sizing by amount ------------------------------------------------------------ + +# The grid a venue with no quantity step is sized on: six decimals, the +# finest any venue in VENUE_RULES uses. +FALLBACK_QUANTITY_STEP = Decimal("0.000001") + + +def outcome_price(yes_price: Decimal, outcome: str, *, face_value: Decimal = ONE) -> Decimal: + """What one contract of `outcome` costs at a YES price: the YES price + itself, or its complement for NO.""" + if outcome not in ("yes", "no"): + raise InvalidOrder(f"outcome must be yes or no, got {outcome!r}") + return yes_price if outcome == "yes" else complement(yes_price, face_value=face_value) + + +def contracts_for_amount( + amount: Decimal, + price: Decimal, + precision: Precision, + *, + action: str = "buy", + worst_price: Decimal | None = None, +) -> Decimal: + """How many contracts `amount` of money buys (or, selling, raises) at + `price`, rounded down to the venue's quantity step. + + `price` and `worst_price` are prices of the token traded, not YES prices: + pass `outcome_price(...)` for NO. Rounding is always down, so the money + spent never exceeds `amount`. `worst_price` caps the spend: a buy at a + price above it (a sell below it) is refused, so `contracts * worst_price` + is at most `amount` too. Refused with `InvalidOrder` when the result is + below the venue's minimum quantity or minimum notional, rather than + rounded up into an order bigger than asked. + """ + amount, price = D(amount), D(price) + if amount <= 0: + raise InvalidOrder(f"amount must be positive, got {amount}") + if price <= 0 or price >= precision.face_value: + raise InvalidOrder(f"price {price} is outside (0, {precision.face_value})") + if action not in ("buy", "sell"): + raise InvalidOrder(f"action must be buy or sell, got {action!r}") + if worst_price is not None: + worst_price = D(worst_price) + worse = price > worst_price if action == "buy" else price < worst_price + if worse: + raise InvalidOrder(f"price {price} is worse than the worst price {worst_price} for a {action}") + step = ONE if precision.whole_contracts else (precision.quantity_step or FALLBACK_QUANTITY_STEP) + contracts = round_to(amount / price, step, "down") + if contracts <= 0 or contracts < precision.min_quantity: + raise InvalidOrder(f"amount {amount} at {price} is {contracts} contracts, below the minimum " + f"{precision.min_quantity}") + if precision.min_notional is not None and contracts * price < precision.min_notional: + raise InvalidOrder(f"amount {amount} at {price} is {contracts * price} after rounding, below the " + f"minimum order of {precision.min_notional}") + return contracts + + +def amount_for_contracts(contracts: Decimal, price: Decimal) -> Decimal: + """What `contracts` cost (or raise) at `price`, the token's own price: + the inverse of `contracts_for_amount`, for display.""" + contracts, price = D(contracts), D(price) + if contracts < 0: + raise InvalidOrder(f"contracts must not be negative, got {contracts}") + return contracts * price + + # -- Kalshi -------------------------------------------------------------------- KALSHI_PRICE_PLACES = Decimal("0.0001") diff --git a/synpath/trading/opinion.py b/synpath/trading/opinion.py index f43bf72..ea08685 100644 --- a/synpath/trading/opinion.py +++ b/synpath/trading/opinion.py @@ -10,9 +10,9 @@ `Order`, signed by the wallet's key for the account's Safe on BNB Chain, which holds the USDT and tokens. See `opinion_signing`. -**YES and NO are separate tokens**, as on Polymarket. `buy` buys the YES -token at the price given; `sell` buys the NO token at `1 - price`; with -`reduce_only`, `sell` sells YES tokens held and `buy` sells NO tokens held. +**YES and NO are separate tokens**, as on Polymarket. An order buys or +sells the token its `outcome` and `action` name (with neither, `buy` buys +YES and `sell` buys NO at `1 - price`). Everything the venue reports on the NO token comes back on the YES leg. **Only resting limits exist.** The venue has no time in force, no post-only @@ -62,18 +62,19 @@ ) VENUE = "opinion" +# The host the venue's SDK sends orders to. TRADING_URL = "https://proxy.opinion.trade:8443/openapi" -"""The host the venue's SDK sends orders to.""" +# Rows per order, trade or position page: the venue's maximum. PAGE = 20 -"""Rows per order, trade or position page: the venue's maximum.""" +# Most pages one listing call walks: 1000 rows. MAX_PAGES = 50 -"""Most pages one listing call walks: 1000 rows.""" +# The venue's documented minimum order value. MIN_ORDER_USDT = Decimal("5") -"""The venue's documented minimum order value.""" +# The venue's numeric order status. ORDER_STATUS = { 1: OrderStatus.OPEN, # pending: resting, or matched and awaiting the chain 2: OrderStatus.CLOSED, # finished @@ -81,25 +82,24 @@ 4: OrderStatus.EXPIRED, 5: OrderStatus.REJECTED, # failed } -"""The venue's numeric order status.""" +# Order statuses as the venue's filter codes. STATUS_CODES = {"open": 1, "closed": 2, "canceled": 3, "expired": 4, "rejected": 5} -"""Order statuses as the venue's filter codes.""" +# The venue's numeric trade status, as a settlement state. TRADE_SETTLEMENT = { 2: SettlementState.CONFIRMED, # finished 3: SettlementState.FAILED, # canceled 5: SettlementState.FAILED, # failed 6: SettlementState.FAILED, # failed on chain } -"""The venue's numeric trade status, as a settlement state.""" +# Trade record sides that are fills. `Split` and `Merge` are conversions +# between collateral and a YES/NO pair, not trades. FILL_SIDES = ("buy", "sell") -"""Trade record sides that are fills. `Split` and `Merge` are conversions -between collateral and a YES/NO pair, not trades.""" +# A market's status once its outcome is final. RESOLVED = 4 -"""A market's status once its outcome is final.""" def D(value: Any) -> Decimal: @@ -248,8 +248,8 @@ class MarketInfo: yes_token: str no_token: str quote_token: str + # The CTF exchange for the market's collateral: the verifying contract. exchange: str - """The CTF exchange for the market's collateral: the verifying contract.""" @property def market_id(self) -> str: @@ -375,6 +375,10 @@ class OpinionTrading(TradingExchange): "fetch_queue_position": False, "fetch_fee_estimate": True, "rfq": False, + # Typed RFQ and combos arrive phase by phase (docs/rfq-combo.md). + "rfq_taker": False, + "rfq_quoter": False, + "combos": False, "split_merge": False, "watch_orders": False, "watch_my_trades": False, @@ -409,13 +413,13 @@ def __init__( from ..opinion import Opinion catalog = Opinion(api_key=credentials.api_key) + # The read adapter: tokens, collateral and fees for a market. self.catalog = catalog - """The read adapter: tokens, collateral and fees for a market.""" + # The account's Safe, which makes every order and holds the funds. self.wallet: str | None = credentials.multisig_address - """The account's Safe, which makes every order and holds the funds.""" self._markets: dict[str, MarketInfo] = {} + # Collateral token address (lowercase) -> its CTF exchange. self._exchanges: dict[str, str] = {} - """Collateral token address (lowercase) -> its CTF exchange.""" # -- plumbing ------------------------------------------------------------- @@ -506,7 +510,8 @@ async def create_order(self, request: OrderRequest) -> Order: price=D(request.price), quantity=contracts, filled=Decimal("0"), - reduce_only=request.reduce_only, + outcome=request.leg[0], + action=request.leg[1], created_at=int(time.time() * 1000), book=request.book, trader=request.trader, diff --git a/synpath/trading/opinion_signing.py b/synpath/trading/opinion_signing.py index a9cb7f6..9c0b427 100644 --- a/synpath/trading/opinion_signing.py +++ b/synpath/trading/opinion_signing.py @@ -39,8 +39,8 @@ MIN_PRICE = Decimal("0.001") MAX_PRICE = Decimal("0.999") +# The venue's bounds on a limit price, as its client enforces them. MAX_PRICE_PLACES = 6 -"""The venue's bounds on a limit price, as its client enforces them.""" ORDER_FIELDS = [ {"name": "salt", "type": "uint256"}, diff --git a/synpath/trading/polymarket.py b/synpath/trading/polymarket.py index 5238a0b..cd6a5c7 100644 --- a/synpath/trading/polymarket.py +++ b/synpath/trading/polymarket.py @@ -10,11 +10,11 @@ `polymarket_signing`, checked byte for byte against the venue's client. **YES and NO are separate tokens with separate books.** An order in this -library names a market and a side on the YES leg; this adapter picks the -token. `buy` buys the YES token at the price given. `sell` buys the NO token -at `1 - price`, because the venue has no netting and you cannot sell YES you -do not hold. With `reduce_only`, `sell` sells the YES tokens held instead, -and `buy` sells the NO tokens held. Everything the venue reports on the NO +library says what it does with `outcome` and `action` (buy or sell, YES or +NO), and this adapter sends exactly that: the outcome's token, bought or +sold, at the outcome's price (a NO price is `1 - ` the YES price). An order +with neither takes its YES-leg side: `buy` buys YES, `sell` buys NO, because +the venue has no netting and you cannot sell YES you do not hold. Everything the venue reports on the NO token comes back converted to the YES leg, and a position is the two token inventories netted, with both kept beside it. The Gamma catalog supplies which token is which; the CLOB never says. @@ -30,6 +30,10 @@ through the gasless relayer for a Deposit Wallet and as the EOA's own Polygon transactions for an EOA; the legacy proxy and Safe payloads are not built here. + +Combo RFQs (requester and quoter) and combos live in `polymarket_rfq`, +signed with Exchange v3 orders from `polymarket_v3_signing`; the requester +side needs a Deposit Wallet, proxy or Safe (EOAs are refused). """ from __future__ import annotations @@ -53,6 +57,7 @@ from ..types import FeeSchedule, Page from . import polymarket_signing as sig from .base import TradingExchange +from .polymarket_rfq import REQUESTER_URL, RFQ_API_URL, PolymarketRfqMixin, is_combo_id, parse_rfq_order_id from .credentials import SYNPATH_BUILDER_CODE, PolymarketCredentials from .errors import CredentialsMissing, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected from .limiter import BudgetLimiter, Priority @@ -71,33 +76,33 @@ GAMMA_URL = "https://gamma-api.polymarket.com" RELAYER_URL = "https://relayer-v2.polymarket.com" +# Synpath's builder signer. Polymarket's relayer accepts a builder's +# credentials in place of the account's own Relayer API key; the builder secret +# stays on Synpath's server, which answers with the headers for one request. +# It moves no money -- the relayer only runs a batch the account's own key +# signed. `SYNPATH_BUILDER_SIGNER_URL` points elsewhere, `none` turns it off. BUILDER_SIGNER_URL = "https://api.synpath.dev/v1/builder/sign" -"""Synpath's builder signer. Polymarket's relayer accepts a builder's -credentials in place of the account's own Relayer API key; the builder secret -stays on Synpath's server, which answers with the headers for one request. -It moves no money -- the relayer only runs a batch the account's own key -signed. `SYNPATH_BUILDER_SIGNER_URL` points elsewhere, `none` turns it off.""" INITIAL_CURSOR = "MA==" END_CURSOR = "LTE=" +# Orders per `POST /orders`, the venue's limit. ORDER_BATCH = 15 -"""Orders per `POST /orders`, the venue's limit.""" +# Ids per `DELETE /orders`, the venue's limit since 2026-06-15. CANCEL_BATCH = 1000 -"""Ids per `DELETE /orders`, the venue's limit since 2026-06-15.""" +# The venue expires a GTD order one minute before its stated expiration. GTD_SAFETY_S = 60 -"""The venue expires a GTD order one minute before its stated expiration.""" +# And refuses an expiration less than three minutes away. GTD_MIN_LEAD_S = 180 -"""And refuses an expiration less than three minutes away.""" +# Shares carry two decimals at every tick size. SIZE_STEP = Decimal("0.01") -"""Shares carry two decimals at every tick size.""" +# How long a token's tick, minimum and neg-risk flag are trusted. Ticks +# change as a price nears the ends; a rejection for a stale tick also clears +# the entry. MARKET_TTL_S = 60.0 -"""How long a token's tick, minimum and neg-risk flag are trusted. Ticks -change as a price nears the ends; a rejection for a stale tick also clears -the entry.""" # --------------------------------------------------------------------------- @@ -180,10 +185,17 @@ def check_token_id(token_id: str) -> str: def wire_leg(request: OrderRequest) -> tuple[str, str]: """`(outcome, venue_side)`: which token the order goes to and whether it - buys or sells that token. See the module docstring.""" - if request.side == Side.BUY: - return ("no", "SELL") if request.reduce_only else ("yes", "BUY") - return ("yes", "SELL") if request.reduce_only else ("no", "BUY") + buys or sells that token: the order's own outcome and action + (`OrderRequest.leg`). See the module docstring.""" + outcome, action = request.leg + return outcome, action.upper() + + +def token_leg(outcome: str, venue_side: str) -> dict[str, str]: + """An order's `outcome` and `action` from the token and the venue's side + (`BUY` / `SELL`), where both are known; nothing otherwise.""" + action = venue_side.strip().lower() + return {"outcome": outcome, "action": action} if outcome in ("yes", "no") and action in ("buy", "sell") else {} def to_yes_leg(outcome: str, venue_side: str, price: Decimal | None) -> tuple[Side, Decimal | None]: @@ -348,6 +360,7 @@ def order_of( account=account, market_id=market_id, side=side, + **token_leg(outcome, str(raw.get("side") or "")), type=OrderType.LIMIT, time_in_force=TIF.get(str(raw.get("order_type") or "GTC").upper(), TimeInForce.GTC), status=status, @@ -386,6 +399,8 @@ def order_from_response( account=account, market_id=ids.qualify(VENUE, ids.native(VENUE, request.market_id)), side=request.side, + outcome=request.leg[0], + action=request.leg[1], type=request.type, time_in_force=TIF[entry["orderType"]], status=status, @@ -537,7 +552,7 @@ def error_of(message: str, *, body: Any = None, status: int | None = None) -> Ex # Adapter # --------------------------------------------------------------------------- -class PolymarketTrading(TradingExchange): +class PolymarketTrading(PolymarketRfqMixin, TradingExchange): """Polymarket CLOB V2 order entry. ```python @@ -574,6 +589,12 @@ class PolymarketTrading(TradingExchange): "fetch_queue_position": False, "fetch_fee_estimate": True, "rfq": False, + # Combo RFQs through the requester and quoter gateways, signed as + # Exchange v3 orders (polymarket_rfq, docs/rfq-combo.md P7). Combos + # are RFQ-only: there is no create or lookup endpoint. + "rfq_taker": True, + "rfq_quoter": True, + "combos": True, # Deposit Wallets through the relayer and EOAs on-chain; the legacy # proxy and Safe wallet payloads are not built. "split_merge": "partial", @@ -596,6 +617,8 @@ def __init__( timeout: float = 30.0, client: Any = None, builder_signer_url: str | None = None, + rfq_requester_url: str = REQUESTER_URL, + rfq_api_url: str = RFQ_API_URL, ): import httpx import os @@ -616,8 +639,8 @@ def __init__( self.data = AsyncHttpClient(data_url, limiter=None, client=shared, venue=VENUE) self.relayer = AsyncHttpClient(relayer_url, limiter=None, client=shared, venue=VENUE) self.gamma = AsyncHttpClient(gamma_url, limiter=None, client=shared, venue=VENUE) + # By Gamma id, condition id and either token id: which token is which side. self._markets: dict[str, MarketTokens] = {} - """By Gamma id, condition id and either token id: which token is which side.""" self._http = shared self._api_key = credentials.api_key self._api_secret = credentials.api_secret @@ -628,6 +651,7 @@ def __init__( self.heartbeat_error: BaseException | None = None self.clock_offset_s = 0 self._default_builder_refused = False + self._init_rfq(shared, requester_url=rfq_requester_url, rfq_api_url=rfq_api_url) """Set once the venue refuses Synpath's default builder code; orders are then signed without it for the rest of this session.""" @@ -921,6 +945,8 @@ async def cancel_all_orders(self, *, market_id: str | None = None) -> int: return len((raw or {}).get("canceled") or []) async def fetch_order(self, order_id: str) -> Order: + if parse_rfq_order_id(order_id) is not None: + return await self.fetch_rfq_order(order_id) raw = await self._private("GET", f"/data/order/{order_id}") if not raw or not isinstance(raw, dict) or not raw.get("id"): raise OrderNotFound(f"polymarket: no order {order_id}") @@ -944,7 +970,12 @@ async def fetch_my_trades( since: int | None = None, limit: int | None = None, cursor: str | None = None, ) -> Page[Fill]: """This account's fills, one page. `order_id` narrows the page to - fills of that order; the venue itself filters by market.""" + fills of that order; the venue itself filters by market. A combo, or + an RFQ trade's order (`rfq:...`), reads the RFQ trades this adapter + holds: they settle on chain, not on the CLOB.""" + if (market_id and is_combo_id(market_id)) or (order_id and parse_rfq_order_id(order_id)): + fills = self.rfq_fills(market_id=market_id, order_id=order_id) + return Page(fills[:limit] if limit else fills, next_cursor=None) condition = (await self.market_tokens(market_id)).condition_id if market_id else None raw = await self._private("GET", "/data/trades", params={ "market": condition, @@ -1371,6 +1402,7 @@ async def _send_transactions( return {"transaction_hashes": hashes, "receipts": receipts} async def close(self) -> None: + await self.stop_rfq_session() await self.stop_heartbeat() await self._http.aclose() diff --git a/synpath/trading/polymarket_rfq.py b/synpath/trading/polymarket_rfq.py new file mode 100644 index 0000000..e191fd0 --- /dev/null +++ b/synpath/trading/polymarket_rfq.py @@ -0,0 +1,1484 @@ +"""Polymarket (global) combo RFQs: requester, quoter and combos (docs/rfq-combo.md, P7). + +`PolymarketTrading` answers the typed RFQ and combo methods through this +mixin. Sources: https://docs.polymarket.com/trading/combos/overview.md, +/trading/combos/requesters.md, /trading/combos/market-makers.md, the RFQ +OpenAPI (/api-spec/combos-rfq-openapi.yaml) and the venue's Python SDK +(`polymarket-client`, `_internal/actions/combo_rfq.py`) for the request and +response shapes. + +**Combos are RFQ-only.** A combo is a conjunction of 2-50 leg outcomes (each +a market's YES or NO *position id*, which is not its CLOB token id) with its +own YES/NO position pair on the PositionManager. The venue has no endpoint +to create or look one up: an RFQ names its legs, and the answer names the +combo (`condition_id`, `yes_position_id`, `no_position_id`). So: + +* a combo the venue has named is `polymarket:combo-`; +* before that, `create_combo(legs)` (offline, no quota) returns it under a + *provisional* id that spells its legs, `polymarket:combo-legs-.` + (each leg position id in hex, ascending), which `create_rfq` reads back + with no state. The RFQ's answer then gives the real id. + +**One side, one quote.** A requester asks for one direction on the combo's +YES (`side` is YES only today): BUY sized by `amount` (pUSD, fees +included), SELL sized by `quantity` (shares). The gateway holds the create +call through the quote competition and answers with the best quote, which +must be accepted within its `expires_at` (five seconds from quote +readiness; the overview says ten). The quote is `ask` (a BUY) or `bid` (a +SELL), the blended YES price; `ask_quantity`/`bid_quantity` is the shares +traded. Accepting signs an Exchange v3 order (`polymarket_v3_signing`) +with the quote's exact maker and taker amounts. + +**What the gateway does not offer**, and what this adapter does instead: + +* No list of a requester's RFQs and no RFQ cancel: `fetch_rfqs` lists the + RFQs this adapter created in this process, and `cancel_rfq` forgets one + locally (an unaccepted RFQ costs nothing; it lapses at `expires_at`). +* Status reads before an accept answer 409, so an RFQ is read from what its + create returned until it is accepted, then from `GET /requests/{id}`. +* **No idempotency key on create.** A create that timed out may have made + an RFQ whose id never arrived. It cannot be accepted (the id is unknown), + so nothing trades, and it lapses within seconds; a retry may make a + second RFQ (the cost is the 15-a-minute create budget). Guards: the + engine journals every create (`rfq-create:...`) and raises + `SubmissionInDoubt`, and `create_rfq` returns an open RFQ this adapter + already holds for the same legs, direction and size instead of creating + another. +* After a restart the adapter holds no RFQs; `remember_rfq(rfq)` re-seeds + one from `Rfq.info` (the engine keeps it in its working set). + +**Quoting runs on a WebSocket.** Quote requests arrive only on the quoter +session (`wss://combos-rfq-gateway-quoter.polymarket.com/ws/rfq`), with a +400 ms submission window; `start_rfq_session()` opens it, and +`fetch_incoming_rfqs` reads what it has received. Quotes, cancels and +last-look confirmations go over REST (`https://combos-rfq-api.polymarket.com`), +each signed with the CLOB L2 headers. + +**Orders.** An RFQ trade settles on chain, not on the CLOB, so its orders +have synthetic ids: the requester's is `rfq:`, a quoter's +`rfq::`. `fetch_order` and `fetch_my_trades` answer them +from the RFQ's state: filled once the gateway reports FILLED/CONFIRMED. +""" +from __future__ import annotations + +import asyncio +import hashlib +import json +import logging +import secrets +import time +from dataclasses import dataclass, field +from decimal import ROUND_HALF_UP, Decimal +from typing import Any, Awaitable, Callable + +from .. import ids +from ..base import AsyncHttpClient +from ..errors import ( + AuthenticationError, BadRequest, ExchangeError, ExchangeNotAvailable, MarketNotFound, NotSupported, + RateLimitExceeded, +) +from ..types import Page +from . import polymarket_signing as sig +from . import polymarket_v3_signing as v3 +from .errors import InsufficientFunds, InvalidOrder, OrderNotFound, OrderRejected, RfqNotFound +from .limiter import Priority +from .types import ( + AcceptQuote, Combo, ComboLeg, ComboMarket, Fill, Liquidity, MakerQuote, Order, OrderStatus, OrderType, QuoteStatus, Rfq, + RfqCloseReason, RfqQuote, RfqStatus, SettlementState, Side, TimeInForce, +) + +log = logging.getLogger("synpath.trading.polymarket") + +VENUE = "polymarket" + +REQUESTER_URL = "https://combos-rfq-gateway-requester-api.polymarket.com" +REQUESTS_PATH = "/v1/requester/rfq/requests" +# The public combo catalogue and the quoter's REST commands. +RFQ_API_URL = "https://combos-rfq-api.polymarket.com" +QUOTER_WS_URL = "wss://combos-rfq-gateway-quoter.polymarket.com/ws/rfq" + +MIN_LEGS, MAX_LEGS = 2, 50 +E6 = Decimal(1_000_000) +# The quoter's BUY-request safety maximum (`QUOTED_PRICE_ABOVE_SAFETY_THRESHOLD`). +BUY_PRICE_CEILING = Decimal("0.90909") +# A quote of ours not selected this long after it was sent is read as +# expired: the competition (400 ms) and the requester's window (5-10 s) are over. +MAKER_QUOTE_STALE_S = 30.0 + +COMBO_PREFIX = "combo-" +PROVISIONAL_PREFIX = "combo-legs-" + +PRE_ACCEPT = frozenset({"CREATED", "COLLECTING_QUOTES", "AWAITING_REQUESTER_ACCEPTANCE"}) +IN_FLIGHT = frozenset({"AWAITING_MAKER_CONFIRMATION", "EXECUTING", "MATCHED", "MINED", "RETRYING"}) +DONE = frozenset({"FILLED", "CONFIRMED"}) +NO_TRADE = frozenset({"FAILED", "EXPIRED", "CANCELED", "REJECTED"}) + +# The gateway's RFQ status as the status of its one quote. FAILED with +# `MAKER_DECLINED` reads as `canceled` (declined in the last look). +QUOTE_STATUS = { + "CREATED": QuoteStatus.OPEN, + "COLLECTING_QUOTES": QuoteStatus.OPEN, + "AWAITING_REQUESTER_ACCEPTANCE": QuoteStatus.OPEN, + "AWAITING_MAKER_CONFIRMATION": QuoteStatus.ACCEPTED, + # The maker confirmed (or has no last look); the trade is on its way to + # the chain. Not a fill until FILLED/CONFIRMED. + "EXECUTING": QuoteStatus.CONFIRMED, + "MATCHED": QuoteStatus.CONFIRMED, + "MINED": QuoteStatus.CONFIRMED, + "RETRYING": QuoteStatus.CONFIRMED, + "FILLED": QuoteStatus.EXECUTED, + "CONFIRMED": QuoteStatus.EXECUTED, + "FAILED": QuoteStatus.FAILED, + "REJECTED": QuoteStatus.FAILED, + "EXPIRED": QuoteStatus.EXPIRED, + "CANCELED": QuoteStatus.CANCELED, +} + +CLOSE_REASON = { + "FILLED": RfqCloseReason.EXECUTED, + "CONFIRMED": RfqCloseReason.EXECUTED, + "EXPIRED": RfqCloseReason.EXPIRED, + "CANCELED": RfqCloseReason.CANCELED, +} + + +# --------------------------------------------------------------------------- +# Ids and amounts +# --------------------------------------------------------------------------- + +def combo_market_id(condition_id: str) -> str: + return ids.qualify(VENUE, f"{COMBO_PREFIX}{condition_id.lower()}") + + +def provisional_combo_id(position_ids: list[str]) -> str: + """A combo's id before the venue names it: its leg position ids, in hex, + ascending, so the id alone gives the legs back.""" + return ids.qualify(VENUE, PROVISIONAL_PREFIX + ".".join(format(int(p), "x") for p in sorted_legs(position_ids))) + + +def legs_of_provisional(combo_id: str) -> list[str] | None: + native = ids.native(VENUE, combo_id) + if not native.startswith(PROVISIONAL_PREFIX): + return None + try: + return sorted_legs([str(int(part, 16)) for part in native[len(PROVISIONAL_PREFIX):].split(".")]) + except ValueError: + raise InvalidOrder(f"polymarket: {combo_id!r} is not a combo id") from None + + +def is_combo_id(market_id: str) -> bool: + try: + return ids.native(VENUE, market_id).startswith(COMBO_PREFIX) + except BadRequest: + return False + + +def combo_market_of(row: dict[str, Any]) -> ComboMarket: + """One catalogue row (`GET /v1/rfq/combo-markets`). `position_ids[0]` is + the YES leg, `[1]` the NO leg.""" + positions = [str(p) for p in row.get("position_ids") or []] + return ComboMarket( + market_id=ids.qualify(VENUE, str(row.get("id") or "")), + venue=VENUE, + title=row.get("title") or row.get("slug") or None, + position_ids=dict(zip(("yes", "no"), positions)) if len(positions) == 2 else None, + min_legs=MIN_LEGS, + max_legs=MAX_LEGS, + status="pending" if row.get("pending") else "open", + tags=[str(t) for t in row.get("tags") or []], + info=row, + ) + + +def sorted_legs(position_ids: list[str]) -> list[str]: + """Leg position ids as the gateway takes them: decimal, unique, ascending.""" + out = [] + for raw in position_ids: + text = str(raw).strip() + if not text.isdecimal(): + raise InvalidOrder(f"polymarket: a combo leg is a numeric position id, not {raw!r}") + out.append(str(int(text))) + if len(set(out)) != len(out): + raise InvalidOrder("polymarket: a combo names each leg once") + if not MIN_LEGS <= len(out) <= MAX_LEGS: + raise InvalidOrder(f"polymarket: a combo has {MIN_LEGS} to {MAX_LEGS} legs, got {len(out)}") + return sorted(out, key=int) + + +def to_e6(value: Decimal, what: str) -> int: + scaled = Decimal(value) * E6 + if scaled != scaled.to_integral_value(): + raise InvalidOrder(f"polymarket: {what} {value} has more than six decimals") + if scaled <= 0: + raise InvalidOrder(f"polymarket: {what} must be positive, got {value}") + return int(scaled) + + +def from_e6(value: Any) -> Decimal | None: + if value in (None, ""): + return None + return Decimal(str(value)) / E6 + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def quote_id_for(key: str | None) -> str: + """A REST quote id: `quote_` and 32 lowercase hex, as the venue makes + them. Derived from the engine's key when there is one, so a retried send + reuses its id.""" + if not key: + return "quote_" + secrets.token_hex(16) + return "quote_" + hashlib.sha256(key.encode()).hexdigest()[:32] + + +def taker_order_id(rfq_id: str) -> str: + return f"rfq:{rfq_id}" + + +def maker_order_id(rfq_id: str, quote_id: str) -> str: + return f"rfq:{rfq_id}:{quote_id}" + + +def parse_rfq_order_id(order_id: str) -> tuple[str, str | None] | None: + """`(rfq_id, quote_id or None)` for an RFQ trade's synthetic order id.""" + if not order_id.startswith("rfq:"): + return None + rest = order_id[4:] + rfq_id, _, quote_id = rest.partition(":") + return (rfq_id, quote_id or None) if rfq_id else None + + +# --------------------------------------------------------------------------- +# Errors +# --------------------------------------------------------------------------- + +REJECT_CODES = { + "EXPIRED_RFQ", "INVALID_RFQ_STATE", "QUOTE_MISMATCH", "REQUEST_FAILED", "INVALID_SIGNATURE", + "INVALID_ACCEPTANCE", "SUBMISSION_WINDOW_CLOSED", "MAKER_QUOTE_LIMITED", "MAKER_NOT_REQUIRED", + "MAKER_ALREADY_RESPONDED", "INVALID_CONFIRMATION", "INVALID_QUOTE", "UNAUTHORIZED_ROLE", +} + + +def rfq_error_of(exc: ExchangeError | RateLimitExceeded | ExchangeNotAvailable) -> Exception: + """The gateway's `{error, code}` body as a typed error. Codes are + strings, and new ones appear: an unknown code keeps its HTTP mapping.""" + body = getattr(exc, "body", None) + code = str(body.get("code") or "") if isinstance(body, dict) else "" + detail = str(body.get("error") or "") if isinstance(body, dict) else "" + message = f"polymarket: {detail or exc}" + (f" ({code})" if code else "") + status = getattr(exc, "status", None) + if code == "UNKNOWN_RFQ" or isinstance(exc, MarketNotFound): + return RfqNotFound(message, body=body, status=status) + if code == "UNSUPPORTED_REQUESTER_SIGNATURE_TYPE": + return InvalidOrder( + message + " -- combo RFQs refuse EOA wallets; use a Deposit Wallet (3), a proxy (1) or a Safe (2)", + body=body, status=status) + if code in ("PRE_EXECUTION_BALANCE_RESERVATION_FAILED", "BALANCE_VALIDATION_FAILED"): + return InsufficientFunds(message, body=body, status=status) + if code == "ALLOWANCE_VALIDATION_FAILED": + return InsufficientFunds(message + " -- an approval is missing; run approve_combo_trading() once", + body=body, status=status) + if isinstance(exc, (AuthenticationError, RateLimitExceeded, ExchangeNotAvailable)): + # 401/403 (UNAUTHENTICATED, ADDRESS_MISMATCH), 429 and 5xx + # (SERVICE_UNAVAILABLE, TRADE_SUBMISSION_FAILED) keep their types: a + # 5xx is in doubt, and a retried accept never executes twice. + if code and not str(exc).endswith(f"({code})"): + exc.args = (f"{exc} ({code})",) + return exc + if code in REJECT_CODES or (code and code.startswith(("SIGNED_ORDER_", "MISSING_", "QUOTED_PRICE_", "ORDER_SIDE_"))): + return OrderRejected(message, reason=code.lower(), info=body if isinstance(body, dict) else {}, + body=body, status=status) + if isinstance(exc, BadRequest): + return InvalidOrder(message, body=body, status=status) + return exc + + +# --------------------------------------------------------------------------- +# What the adapter holds about each RFQ +# --------------------------------------------------------------------------- + +@dataclass +class RfqRecord: + """One RFQ this adapter created, as the gateway described it.""" + + rfq_id: str + # The create body as sent. + request: dict[str, Any] + # The create answer: status, expiry, the echoed request, the quote. + response: dict[str, Any] + status: str + created_at: int + combo: Combo | None = None + # The signed accept, kept so a retry sends the same order. + accept_body: dict[str, Any] | None = None + accept_side: Side | None = None + accepted_at: int | None = None + order_hash: str | None = None + # The latest status answer after the accept. + last: dict[str, Any] = field(default_factory=dict) + canceled: bool = False + + @property + def echoed(self) -> dict[str, Any]: + return self.response.get("request") or {} + + @property + def quote(self) -> dict[str, Any] | None: + return self.response.get("quote") or None + + @property + def direction(self) -> str: + return str(self.echoed.get("direction") or self.request.get("direction")) + + @property + def expires_at(self) -> int | None: + value = self.response.get("expires_at") + return int(value) if value not in (None, "") else None + + @property + def error(self) -> dict[str, Any] | None: + return self.last.get("error") or self.response.get("error") or None + + def wire_status(self, now_ms: int | None = None) -> str: + """The gateway's status, with an unaccepted quote past its deadline + read as EXPIRED and a locally cancelled one as CANCELED.""" + if self.accept_body is None and self.status in PRE_ACCEPT: + if self.canceled: + return "CANCELED" + if self.expires_at is not None and (now_ms or _now_ms()) >= self.expires_at: + return "EXPIRED" + return self.status + + +@dataclass +class MakerRecord: + """One quote of ours on someone else's RFQ.""" + + quote: RfqQuote + rfq_id: str + request: dict[str, Any] + sent_at: int + # OPEN, ACCEPTED (a last-look request), CONFIRMED (handed to execution), + # EXECUTED, FAILED, CANCELED, EXPIRED. + status: str = "OPEN" + fill_size_e6: int | None = None + tx_hash: str | None = None + detail: dict[str, Any] = field(default_factory=dict) + + +MAKER_STATUS = { + "OPEN": QuoteStatus.OPEN, "ACCEPTED": QuoteStatus.ACCEPTED, "CONFIRMED": QuoteStatus.CONFIRMED, + "EXECUTED": QuoteStatus.EXECUTED, "FAILED": QuoteStatus.FAILED, "CANCELED": QuoteStatus.CANCELED, + "EXPIRED": QuoteStatus.EXPIRED, +} +EXECUTION_TO_MAKER = {"MATCHED": "CONFIRMED", "MINED": "CONFIRMED", "RETRYING": "CONFIRMED", + "EXECUTING": "CONFIRMED", "CONFIRMED": "EXECUTED", "FILLED": "EXECUTED", "FAILED": "FAILED"} + + +# --------------------------------------------------------------------------- +# The quoter session (WebSocket) +# --------------------------------------------------------------------------- + +class PolymarketRfqSession: + """The quoter's WebSocket: authenticates, then hands every message to + the adapter. Quote requests (`RFQ_REQUEST`) become incoming RFQs; a + last-look request marks our quote accepted; execution updates and trade + broadcasts move our quotes on. The gateway's ping frames are answered by + the WebSocket library. `on_rfq` (optional) is called with each new + incoming `Rfq`, for a quoter that must answer inside 400 ms.""" + + def __init__(self, adapter: "PolymarketRfqMixin", *, url: str = QUOTER_WS_URL, + connect: Callable[[str], Any] | None = None, + on_rfq: Callable[[Rfq], Awaitable[None] | None] | None = None): + self.adapter = adapter + self.url = url + self._connect = connect + self.on_rfq = on_rfq + self.authenticated: bool | None = None + self.auth_error: str | None = None + self.role: str | None = None + self.errors: list[dict[str, Any]] = [] + self._task: asyncio.Task | None = None + self._ws: Any = None + + def auth_message(self) -> dict[str, Any]: + a = self.adapter + signer, maker = v3.order_identity(a.credentials.signature_type, signer=a.signer.address, wallet=a.wallet) + return { + "type": "auth", + "auth": {"apiKey": a._api_key or "", "secret": a._api_secret or "", "passphrase": a._api_passphrase or ""}, + "identity": {"signer_address": signer, "maker_address": maker, + "signature_type": a.credentials.signature_type}, + } + + async def run(self) -> None: + """Connect, authenticate, read until closed.""" + await self.adapter.ensure_api_credentials() + connect = self._connect + if connect is None: + import websockets + + connect = websockets.connect + async with connect(self.url) as ws: + self._ws = ws + await ws.send(json.dumps(self.auth_message())) + async for raw in ws: + await self.handle(json.loads(raw) if isinstance(raw, (str, bytes)) else raw) + self._ws = None + + def start(self) -> asyncio.Task: + if self._task is None or self._task.done(): + self._task = asyncio.get_running_loop().create_task(self.run(), name="synpath-polymarket-rfq") + return self._task + + async def close(self) -> None: + if self._ws is not None: + try: + await self._ws.close() + except Exception: # pragma: no cover - closing a dead socket + pass + if self._task is not None: + self._task.cancel() + try: + await self._task + except (asyncio.CancelledError, Exception): + pass + self._task = None + + async def handle(self, message: dict[str, Any]) -> None: + kind = message.get("type") + a = self.adapter + if kind == "auth": + self.authenticated = bool(message.get("success")) + self.role = message.get("role") + self.auth_error = None if self.authenticated else str(message.get("error") or "refused") + if not self.authenticated: + log.error("polymarket: the RFQ quoter session was refused: %s", self.auth_error) + elif kind == "RFQ_REQUEST": + rfq = a._incoming_from(message) + if rfq is not None: + # The engine's listeners first (they are scheduled, never + # awaited here, so the socket keeps reading), then `on_rfq`. + a._tell_rfq_listeners(rfq) + if self.on_rfq is not None: + result = self.on_rfq(rfq) + if asyncio.iscoroutine(result): + await result + elif kind == "RFQ_CONFIRMATION_REQUEST": + a._maker_event(message, "ACCEPTED") + elif kind == "RFQ_EXECUTION_UPDATE": + a._maker_execution(message) + elif kind == "RFQ_TRADE": + a._maker_trade(message) + elif kind == "RFQ_ERROR": + self.errors.append(message) + log.warning("polymarket: RFQ session error on %s: %s (%s)", message.get("request_type"), + message.get("error"), message.get("code")) + a._maker_error(message) + + +# --------------------------------------------------------------------------- +# The mixin +# --------------------------------------------------------------------------- + +class PolymarketRfqMixin: + """Combo RFQs on `PolymarketTrading`. See the module doc.""" + + # The requester gateway has no cancel: `cancel_rfq` only drops the RFQ + # here and it lapses at `expires_at`. Read by the engine's cross-venue + # groups (`synpath.engine.rfq_group`), which record such a venue as left + # to expire rather than cancelled. + rfq_requester_cancel = False + # An RFQ asks one side (YES, `buy` by amount or `sell` by quantity). + rfq_one_sided = True + + # Set by `PolymarketTrading.__init__` through `_init_rfq`. + requester: AsyncHttpClient + rfq_api: AsyncHttpClient + + def _init_rfq(self, shared: Any, *, requester_url: str = REQUESTER_URL, rfq_api_url: str = RFQ_API_URL) -> None: + self.requester = AsyncHttpClient(requester_url, limiter=None, client=shared, venue=VENUE) + self.rfq_api = AsyncHttpClient(rfq_api_url, limiter=None, client=shared, venue=VENUE) + self._rfq_records: dict[str, RfqRecord] = {} + self._quote_to_rfq: dict[str, str] = {} + # By combo id (real and provisional) and by its sorted leg set. + self._combos: dict[str, Combo] = {} + # Leg position id -> (Synpath market id, outcome). + self._positions: dict[str, tuple[str, str]] = {} + self._position_of: dict[tuple[str, str], str] = {} + self._incoming: dict[str, tuple[Rfq, dict[str, Any]]] = {} + self._maker: dict[str, MakerRecord] = {} + self.rfq_session: PolymarketRfqSession | None = None + self._rfq_listeners: list[Callable[[Rfq], Any]] = [] + self._listener_tasks: set[asyncio.Task] = set() + + # -- signing and calls ---------------------------------------------------- + + def _rfq_identity(self) -> tuple[str, str, int]: + kind = self.credentials.signature_type + signer, maker = v3.order_identity(kind, signer=self.signer.address, wallet=self.wallet) + return signer, maker, kind + + async def _gateway( + self, client: AsyncHttpClient, method: str, path: str, *, body: Any = None, + kind: str = "read", priority: Priority = Priority.NORMAL, + ) -> Any: + """One CLOB-L2-signed call to an RFQ gateway, errors typed.""" + from .polymarket import serialize + + await self.ensure_api_credentials() + await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type] + text = serialize(body) if body is not None else None + headers = sig.l2_headers( + address=self.signer.address, api_key=self._api_key or "", secret=self._api_secret or "", + passphrase=self._api_passphrase or "", timestamp=self._now_s(), method=method, path=path, body=text, + ) + if text is not None: + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + try: + return await client.request(method, path, content=text, headers=headers) + except (ExchangeError, RateLimitExceeded, ExchangeNotAvailable) as exc: + raise rfq_error_of(exc) from None + + # -- legs and combos ------------------------------------------------------ + + def remember_combo_market(self, raw: dict[str, Any]) -> None: + """Index one combo-enabled market (a catalogue row, or a Gamma row + with `positionIds`): position id <-> market and outcome.""" + gamma_id = str(raw.get("id") or "") + positions = raw.get("position_ids") or raw.get("positionIds") or [] + if isinstance(positions, str): + positions = json.loads(positions or "[]") + if not gamma_id or len(positions) != 2: + return + market = ids.qualify(VENUE, gamma_id) + for outcome, pid in zip(("yes", "no"), positions): + self._positions[str(int(pid))] = (market, outcome) + self._position_of[(market, outcome)] = str(int(pid)) + + async def fetch_combo_markets( + self, *, limit: int | None = None, cursor: str | None = None, exclude: list[str] | None = None, + ) -> Page[dict[str, Any]]: + """The public catalogue of combo-enabled markets, by volume (`GET + /v1/rfq/combo-markets`, no auth). `position_ids[0]` is the YES leg, + `[1]` the NO leg; every row is indexed for leg lookups.""" + await self.limiter.acquire(cost=1, kind="read") + raw = await self.rfq_api.get("/v1/rfq/combo-markets", { + "limit": limit, "cursor": cursor, "exclude": ",".join(exclude) if exclude else None, + }) + rows = (raw or {}).get("markets") or [] + for row in rows: + self.remember_combo_market(row) + return Page(rows, next_cursor=(raw or {}).get("next_cursor") or None) + + async def fetch_combo_eligible_markets( + self, *, event_id: str | None = None, tag: str | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[ComboMarket]: + """`fetch_combo_markets` as typed rows: the catalogue's markets, by + volume, with their YES/NO position ids. The catalogue takes no + filter, so `tag` is applied to each page as it comes (a page can come + back short, or empty with a cursor), and `event_id` is refused: the + catalogue does not name events.""" + if event_id is not None: + raise BadRequest("polymarket: the combo catalogue does not name events; filter by tag") + page = await self.fetch_combo_markets(limit=limit, cursor=cursor) + rows = [combo_market_of(row) for row in page] + if tag: + rows = [row for row in rows if tag.lower() in (t.lower() for t in row.tags)] + return Page(rows, next_cursor=page.next_cursor) + + async def leg_position_id(self, leg: ComboLeg) -> str: + """A leg's position id: `leg.info["position_id"]`, else the index, + else the market's Gamma row (`positionIds`, UNVERIFIED on `/markets/{id}`).""" + given = leg.info.get("position_id") + if given: + pid = str(int(str(given))) + self._positions.setdefault(pid, (leg.market_id, leg.outcome)) + self._position_of.setdefault((leg.market_id, leg.outcome), pid) + return pid + found = self._position_of.get((leg.market_id, leg.outcome)) + if found: + return found + native = ids.native(VENUE, leg.market_id) + if native.isdecimal(): + await self.limiter.acquire(cost=1, kind="read") + try: + raw = await self.gamma.get(f"/markets/{native}") + except MarketNotFound: + raw = None + if isinstance(raw, dict): + self.remember_combo_market(raw) + found = self._position_of.get((leg.market_id, leg.outcome)) + if found: + return found + raise InvalidOrder( + f"polymarket: no combo position id known for {leg.market_id} {leg.outcome}; load the catalogue " + "(fetch_combo_markets) or give leg.info['position_id']" + ) + + def _leg_of(self, position_id: str) -> ComboLeg: + known = self._positions.get(position_id) + if known is None: + # Not in the index: named by its position id until it is resolved. + return ComboLeg(market_id=ids.qualify(VENUE, f"position-{position_id}"), outcome="yes", + info={"position_id": position_id, "resolved": False}) + return ComboLeg(market_id=known[0], outcome=known[1], info={"position_id": position_id}) + + async def _resolve_positions(self, position_ids: list[str]) -> None: + """Index unknown leg position ids through Gamma (`GET /markets/keyset?position_ids=`).""" + missing = [p for p in position_ids if p not in self._positions] + if not missing: + return + await self.limiter.acquire(cost=1, kind="read") + try: + raw = await self.gamma.get("/markets/keyset", [("position_ids", p) for p in missing]) + except Exception as exc: # display only: a leg stays named by its position id + log.warning("polymarket: resolving combo legs %s failed: %s", missing, exc) + return + rows = raw if isinstance(raw, list) else (raw or {}).get("markets") or (raw or {}).get("data") or [] + for row in rows: + if isinstance(row, dict): + self.remember_combo_market(row) + + def _combo(self, position_ids: list[str], *, condition_id: str | None = None, + extra: dict[str, Any] | None = None) -> Combo: + legs = sorted_legs(position_ids) + market_id = combo_market_id(condition_id) if condition_id else provisional_combo_id(legs) + info = {"leg_position_ids": legs, **(extra or {})} + if condition_id is None: + info["provisional"] = True + combo = Combo(market_id=market_id, venue=VENUE, legs=[self._leg_of(p) for p in legs], + status="open" if condition_id else None, info=info) + self._combos[market_id] = combo + if condition_id: + self._combos["legs:" + ",".join(legs)] = combo + self._combos[provisional_combo_id(legs)] = combo + return combo + + async def create_combo(self, legs: list[ComboLeg], *, collection_id: str | None = None) -> Combo: + """The combo for these legs. The venue has no create (an RFQ names a + combo), so nothing is sent and no quota is used: each leg's position + id is resolved, and the combo comes back under its venue id if an RFQ + has named it, else under its provisional id (module doc). The venue + has no collections, so a `collection_id` is refused.""" + if collection_id is not None: + raise InvalidOrder(f"polymarket: combos are not built in collections; drop collection_id " + f"({collection_id!r})") + if not MIN_LEGS <= len(legs) <= MAX_LEGS: + raise InvalidOrder(f"polymarket: a combo has {MIN_LEGS} to {MAX_LEGS} legs, got {len(legs)}") + if len({leg.market_id for leg in legs}) != len(legs): + raise InvalidOrder("polymarket: a combo names each market once") + positions = sorted_legs([await self.leg_position_id(leg) for leg in legs]) + known = self._combos.get("legs:" + ",".join(positions)) + return known or self._combo(positions) + + async def fetch_combo(self, combo_id: str) -> Combo: + """A combo this adapter has seen, or one named by its provisional id.""" + combo_id = ids.qualify(VENUE, combo_id) + if combo_id in self._combos: + return self._combos[combo_id] + legs = legs_of_provisional(combo_id) + if legs is not None: + await self._resolve_positions(legs) + return self._combo(legs) + raise RfqNotFound(f"polymarket: combo {combo_id} has not been seen in this session; the venue has no " + "combo lookup -- use its legs (create_combo)") + + async def lookup_combo(self, legs: list[ComboLeg]) -> Combo | None: + raise NotSupported( + "polymarket: the venue has no combo lookup; create_combo answers offline (no quota) with the " + "combo's id once an RFQ has named it, else its provisional id" + ) + + async def approve_combo_trading(self, *, include_router: bool = False, + include_auto_redeem: bool = False) -> dict[str, Any]: + """Approve the combo contracts from the wallet (`maker_address`), + once per wallet, through the existing wallet-call path. + + Always: pUSD `approve(Exchange v3)` and PositionManager + `setApprovalForAll(Exchange v3)`, which a quoter's orders need to move + collateral and combo positions. `include_router` adds pUSD + `approve(Router)` and PositionManager `setApprovalForAll(Router)`, for + splitting pUSD into positions and managing or redeeming them through + the Router; `include_auto_redeem` adds PositionManager + `setApprovalForAll(AutoRedeemer)`, for automatic redemption. The + venue's docs list these for market makers; whether a requester needs + any of them is UNVERIFIED (docs/rfq-combo.md).""" + return await self.execute_wallet_calls( + v3.combo_approval_calls(include_router=include_router, include_auto_redeem=include_auto_redeem), + description="Approve Combo RFQ contracts") + + # -- normalisers ---------------------------------------------------------- + + def _rfq_from_record(self, rec: RfqRecord) -> Rfq: + wire = rec.wire_status() + echoed = {**rec.request, **rec.echoed} + condition = echoed.get("condition_id") + combo = rec.combo + if condition and (combo is None or combo.market_id != combo_market_id(condition)): + extra = {k: echoed[k] for k in ("condition_id", "yes_position_id", "no_position_id") if echoed.get(k)} + combo = self._combos.get(combo_market_id(condition)) or self._combo( + echoed.get("leg_position_ids") or rec.request["leg_position_ids"], condition_id=condition, extra=extra) + rec.combo = combo + size = echoed.get("requested_size") or {} + value = from_e6(size.get("value_e6")) + closed = wire in DONE or wire in NO_TRADE + return Rfq( + id=rec.rfq_id, venue=VENUE, account=self.account, + combo_id=combo.market_id if combo else provisional_combo_id(rec.request["leg_position_ids"]), + combo=combo, + outcome="yes" if str(echoed.get("side") or "YES") == "YES" else "no", + action="buy" if rec.direction == "BUY" else "sell", + amount=value if size.get("unit") == "notional" else None, + quantity=value if size.get("unit") == "shares" else None, + status=RfqStatus.CLOSED if closed else RfqStatus.OPEN, + closed_reason=CLOSE_REASON.get(wire) if closed else None, + mine=True, created_at=int(echoed.get("created_at") or rec.created_at), + expires_at=rec.expires_at, updated_at=rec.accepted_at, + info={"request": rec.request, "response": rec.response, "status": wire, **({"last": rec.last} if rec.last else {}), + **({"error": rec.error} if rec.error else {})}, + ) + + def _quote_from_record(self, rec: RfqRecord) -> RfqQuote | None: + quote = rec.quote + if not quote: + return None + wire = rec.wire_status() + status = QUOTE_STATUS.get(wire, QuoteStatus.OPEN) + error = rec.error or {} + if wire == "FAILED" and error.get("code") == "MAKER_DECLINED": + status = QuoteStatus.CANCELED + price = from_e6(quote.get("blended_price_e6")) + maker_amount, taker_amount = int(quote["maker_amount_e6"]), int(quote["taker_amount_e6"]) + buy = rec.direction == "BUY" + shares = from_e6(taker_amount if buy else maker_amount) + combo = rec.combo + accepted = rec.accept_side if rec.accept_body is not None else None + return RfqQuote.from_venue(dict( + id=str(quote.get("quote_id") or ""), rfq_id=rec.rfq_id, venue=VENUE, + market_id=combo.market_id if combo else None, + ask=price if buy else None, bid=None if buy else price, + ask_quantity=shares if buy else None, bid_quantity=None if buy else shares, + status=status, accepted_side=accepted, expires_at=rec.expires_at, + taker_order_id=taker_order_id(rec.rfq_id) if status == QuoteStatus.EXECUTED else None, + mine=False, created_at=int(rec.echoed.get("created_at") or rec.created_at), updated_at=rec.accepted_at, + info={"quote": quote, "status": wire, "direction": rec.direction, + "total_required": str(from_e6(quote.get("total_required_e6"))), + "net_receive": str(from_e6(quote.get("net_receive_e6"))), + **({"order_hash": rec.order_hash} if rec.order_hash else {}), + **({"taker_order_hash": rec.last.get("taker_order_hash")} if rec.last.get("taker_order_hash") else {}), + **({"tx_hash": rec.last.get("tx_hash")} if rec.last.get("tx_hash") else {}), + **({"error": error} if error else {})}, + )) + + def remember_rfq(self, rfq: Rfq) -> None: + """Re-seed an RFQ this adapter created (after a restart) from the + `Rfq` it returned, whose `info` keeps the request and the answer.""" + request, response = rfq.info.get("request"), rfq.info.get("response") + if not isinstance(request, dict) or not isinstance(response, dict): + raise BadRequest("polymarket: this Rfq has no request and response in info to rebuild from") + rec = RfqRecord(rfq_id=rfq.id, request=request, response=response, status=str(response.get("status") or ""), + created_at=rfq.created_at or _now_ms(), combo=rfq.combo) + if rfq.info.get("status") not in (None, *PRE_ACCEPT): + # It was accepted: the status read works now. + rec.accept_body = {"quote_id": (response.get("quote") or {}).get("quote_id")} + rec.accept_side = Side.BUY if rec.direction == "BUY" else Side.SELL + rec.status = str(rfq.info.get("status")) + self._keep(rec) + + def restore_rfqs(self, rfqs: list[Rfq], quotes: list[RfqQuote] | None = None) -> None: + """After a restart (the engine calls this from `recover()`): re-seed + the RFQs this account created, since the gateway lists none, and + the quotes whose accept or confirm was in doubt. + + * An RFQ already held is kept as it is (this process knows more). + * A quote of someone else's (`mine=False`) on one of those RFQs, + reported `accepted` or later, marks that RFQ accepted: its status + is then read from `GET /requests/{id}`, so the accept is followed + to FILLED or FAILED. If the accept never reached the gateway, the + read answers 409 and the quote stays `open` (never accepted). + * A quote of ours (`mine=True`, the `RfqQuote` that `send_quote` + returned, with `info["request"]`) is re-seeded at its status, so a + confirm in doubt reads `accepted`/`confirmed` rather than unknown. + Its execution arrives only on the quoter session.""" + for rfq in rfqs: + if rfq.id in self._rfq_records: + continue + try: + self.remember_rfq(rfq) + except BadRequest as exc: + log.warning("polymarket: RFQ %s cannot be re-seeded: %s", rfq.id, exc) + for quote in quotes or (): + if quote.mine: + self._remember_maker_quote(quote) + continue + rec = self._rfq_records.get(quote.rfq_id) + if rec is None or rec.accept_body is not None or quote.status == QuoteStatus.OPEN: + continue + raw = rec.quote or {} + if str(raw.get("quote_id") or "") != quote.id: + continue + rec.accept_body = {"quote_id": quote.id, "restored": True} + rec.accept_side = quote.accepted_side or (Side.BUY if rec.direction == "BUY" else Side.SELL) + rec.accepted_at = quote.updated_at or rec.created_at + + def _remember_maker_quote(self, quote: RfqQuote) -> None: + if quote.id in self._maker: + return + request = quote.info.get("request") + if not isinstance(request, dict) or not request.get("size_e6"): + log.warning("polymarket: our quote %s has no request in info; it cannot be re-seeded", quote.id) + return + status = {QuoteStatus.OPEN: "OPEN", QuoteStatus.ACCEPTED: "ACCEPTED", QuoteStatus.CONFIRMED: "CONFIRMED", + QuoteStatus.EXECUTED: "EXECUTED", QuoteStatus.FAILED: "FAILED", QuoteStatus.CANCELED: "CANCELED", + QuoteStatus.EXPIRED: "EXPIRED"}[quote.status] + info = {k: v for k, v in quote.info.items() + if k not in ("status", "confirmation_request", "execution", "confirm", "cancel", "restored")} + base = quote.model_copy(update={"status": QuoteStatus.OPEN, "accepted_side": None, "info": info}) + held = MakerRecord(quote=base, rfq_id=quote.rfq_id, request=request, sent_at=quote.created_at or _now_ms(), + status=status, detail={"restored": True}) + shares = quote.ask_quantity if quote.ask is not None else quote.bid_quantity + if shares is not None and status != "OPEN": + size = int(Decimal(shares) * E6) + if size != int(request["size_e6"]): + held.fill_size_e6 = size # the part the requester took (`fill_size_e6`) + if quote.confirm_by is not None: + held.detail["confirm_by"] = int(quote.confirm_by) + self._maker[quote.id] = held + + def _keep(self, rec: RfqRecord) -> None: + self._rfq_records[rec.rfq_id] = rec + quote = rec.quote + if quote and quote.get("quote_id"): + self._quote_to_rfq[str(quote["quote_id"])] = rec.rfq_id + + # -- requester ------------------------------------------------------------ + + async def create_rfq( + self, instrument_id: str, *, quantity: Decimal | None = None, amount: Decimal | None = None, + outcome: str | None = None, action: str | None = None, rest_remainder: bool = False, + combo: Combo | None = None, + ) -> Rfq: + """`POST /v1/requester/rfq/requests`: ask the quoters for one side of + a combo's YES. `action="buy"` is sized by `amount` (pUSD, fees + included), `action="sell"` by `quantity` (shares). `instrument_id` is + a combo id (venue or provisional); `combo` gives the legs instead. + The gateway answers after the competition with the best quote, which + `fetch_quotes` returns; an answer with no usable quote is a closed RFQ + (`info["error"]`, e.g. `NO_QUOTES`). No idempotency key: see the module doc.""" + v3.check_requester(self.credentials.signature_type) + if rest_remainder: + raise InvalidOrder("polymarket: a combo RFQ cannot rest a remainder; there is no combo book") + if outcome not in (None, "yes"): + raise InvalidOrder("polymarket: a combo RFQ trades the combo's YES only (the venue's side is YES today)") + if action not in ("buy", "sell"): + raise InvalidOrder("polymarket: a combo RFQ asks one side: give action='buy' or 'sell'") + if action == "buy": + if amount is None or quantity is not None: + raise InvalidOrder("polymarket: a BUY combo RFQ is sized by amount (pUSD, fees included), not quantity") + requested = {"unit": "notional", "value_e6": str(to_e6(amount, "amount"))} + else: + if quantity is None or amount is not None: + raise InvalidOrder("polymarket: a SELL combo RFQ is sized by quantity (shares), not amount") + requested = {"unit": "shares", "value_e6": str(to_e6(quantity, "quantity"))} + legs = await self._legs_for(instrument_id, combo) + direction = "BUY" if action == "buy" else "SELL" + # The guard for a create with no idempotency key: an open RFQ we + # already hold for the same request is returned, not made again. + for held in (await self.fetch_rfqs(status=RfqStatus.OPEN)): + rec = self._rfq_records.get(held.id) + if rec is not None and rec.accept_body is None and rec.request.get("leg_position_ids") == legs \ + and rec.request.get("direction") == direction and rec.request.get("requested_size") == requested: + log.info("polymarket: RFQ %s is already open for this request; returned instead of a new one", rec.rfq_id) + return held + await self._resolve_positions(legs) + signer, maker, kind = self._rfq_identity() + body = { + "signer_address": signer, "maker_address": maker, "signature_type": kind, + "leg_position_ids": legs, "direction": direction, "side": "YES", "requested_size": requested, + } + raw = await self._gateway(self.requester, "POST", REQUESTS_PATH, body=body, kind="write") + if not isinstance(raw, dict) or not raw.get("rfq_id"): + raise ExchangeError("polymarket: the RFQ create answered with no rfq_id", body=raw) + provisional = combo if combo is not None and not combo.info.get("condition_id") else None + rec = RfqRecord(rfq_id=str(raw["rfq_id"]), request=body, response=raw, status=str(raw.get("status") or ""), + created_at=_now_ms(), combo=None) + self._keep(rec) + rfq = self._rfq_from_record(rec) + if rec.combo is None: + rec.combo = provisional or self._combo(legs) + rfq = self._rfq_from_record(rec) + return rfq + + async def _legs_for(self, instrument_id: str, combo: Combo | None) -> list[str]: + if combo is not None: + given = combo.info.get("leg_position_ids") + if given: + return sorted_legs(list(given)) + return sorted_legs([await self.leg_position_id(leg) for leg in combo.legs]) + instrument_id = ids.qualify(VENUE, instrument_id) + known = self._combos.get(instrument_id) + if known is not None: + return sorted_legs(list(known.info.get("leg_position_ids") or [])) + legs = legs_of_provisional(instrument_id) + if legs is not None: + return legs + if is_combo_id(instrument_id): + raise InvalidOrder(f"polymarket: combo {instrument_id} has not been seen in this session; give combo= " + "(its legs) or its provisional id from create_combo") + raise InvalidOrder("polymarket: RFQs quote combos only; a single market trades on the CLOB") + + def _record(self, rfq_id: str | None = None, quote_id: str | None = None) -> RfqRecord: + rec = self._rfq_records.get(rfq_id or "") or self._rfq_records.get(self._quote_to_rfq.get(quote_id or "", "")) + if rec is None: + raise RfqNotFound( + f"polymarket: RFQ {rfq_id or quote_id} was not created by this adapter in this process; the gateway " + "lists no RFQs and reads none before its accept (remember_rfq re-seeds one)" + ) + return rec + + async def _refresh(self, rec: RfqRecord) -> RfqRecord: + """Read an accepted RFQ's status (`GET /requests/{id}`); before the + accept the gateway answers 409, so the create's answer stands.""" + if rec.accept_body is None or rec.status in DONE or rec.status in NO_TRADE: + return rec + try: + raw = await self._gateway(self.requester, "GET", f"{REQUESTS_PATH}/{rec.rfq_id}") + except (OrderRejected, InvalidOrder) as exc: + if exc.status == 409: + return rec + raise + if isinstance(raw, dict) and raw.get("status"): + if raw.get("rfq_id") not in (None, rec.rfq_id): + raise ExchangeError(f"polymarket: asked for RFQ {rec.rfq_id}, got {raw.get('rfq_id')}", body=raw) + rec.status = str(raw["status"]) + rec.last = {**rec.last, **raw} + return rec + + async def fetch_rfq(self, rfq_id: str) -> Rfq: + return self._rfq_from_record(await self._refresh(self._record(rfq_id))) + + async def fetch_rfqs( + self, *, status: RfqStatus | str | None = None, market_id: str | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Rfq]: + """The RFQs this adapter created in this process (the gateway lists none).""" + rows = [self._rfq_from_record(rec) for rec in self._rfq_records.values()] + if status is not None: + rows = [r for r in rows if r.status == RfqStatus(status)] + if market_id is not None: + wanted = ids.qualify(VENUE, market_id) + rows = [r for r in rows if r.combo_id == wanted + or (r.combo is not None and provisional_combo_id(r.combo.info.get("leg_position_ids") or []) == wanted)] + rows.sort(key=lambda r: r.created_at or 0, reverse=True) + return Page(rows[:limit] if limit else rows, next_cursor=None) + + async def cancel_rfq(self, rfq_id: str) -> None: + """The gateway has no cancel for a requester. An unaccepted RFQ is + dropped here (it lapses at `expires_at`, and nothing trades until + accepted); an accepted one cannot be withdrawn.""" + rec = self._record(rfq_id) + if rec.accept_body is not None and rec.status not in NO_TRADE: + raise OrderRejected(f"polymarket: RFQ {rfq_id} was accepted ({rec.status}); it cannot be cancelled", + reason="already_accepted") + rec.canceled = True + + async def fetch_quotes(self, rfq_id: str, *, status: QuoteStatus | str | None = None) -> list[RfqQuote]: + """The RFQ's quote (the gateway returns the best one only). An RFQ + that ended without a trade (expired, cancelled, failed, no quotes) + is `RfqNotFound`, as the gateway itself no longer serves it.""" + rec = await self._refresh(self._record(rfq_id)) + wire = rec.wire_status() + if wire in NO_TRADE: + raise RfqNotFound(f"polymarket: RFQ {rfq_id} ended without a trade ({wire})") + quote = self._quote_from_record(rec) + quotes = [quote] if quote is not None else [] + if status is not None: + quotes = [q for q in quotes if q.status == QuoteStatus(status)] + return quotes + + async def fetch_quote(self, quote_id: str, *, rfq_id: str | None = None) -> RfqQuote: + if quote_id in self._maker: + return self._maker_quote(self._maker[quote_id]) + rec = await self._refresh(self._record(rfq_id, quote_id)) + quote = self._quote_from_record(rec) + if quote is None or quote.id != quote_id: + raise RfqNotFound(f"polymarket: no quote {quote_id} on RFQ {rec.rfq_id}") + return quote + + async def accept_quote(self, accept: AcceptQuote) -> RfqQuote: + """`POST /requests/{rfq}/accept` with a signed Exchange v3 order. + + The order buys (or sells) the combo's YES position for exactly the + quote's `maker_amount_e6`/`taker_amount_e6`, signed for the wallet + type (`polymarket_v3_signing`), with a zero builder (the requester + gateway refuses attribution). Refused before anything is signed when + the quote is not open, past its `expires_at`, on the other side, or + worse than `worst_price` -- either its blended price or the price its + order commits to (`maker/taker`), whichever is worse; fees come on + top (`info["total_required"]`). Full size only. + + A retry is safe: an accepted quote is returned without sending again, + and the gateway does not execute the same acceptance twice. Returns at + once; `executed` comes when the gateway reports FILLED/CONFIRMED, and + `taker_order_id` is then `rfq:`. + """ + v3.check_requester(self.credentials.signature_type) + if accept.quantity is not None or accept.amount is not None: + raise InvalidOrder("polymarket: a combo quote is accepted for its full size; the venue takes no partial size") + rec = self._record(accept.rfq_id, accept.quote_id) + quote_raw = rec.quote + if not quote_raw or str(quote_raw.get("quote_id")) != accept.quote_id: + raise RfqNotFound(f"polymarket: no quote {accept.quote_id} on RFQ {rec.rfq_id}") + side = accept.side + if rec.accept_body is not None and rec.accept_body.get("restored"): + await self._refresh(rec) + if rec.status in PRE_ACCEPT: + # Re-seeded as possibly accepted, but the gateway still has it + # before the accept (409): the accept never landed; send it. + rec.accept_body, rec.accept_side, rec.accepted_at = None, None, None + if rec.accept_body is not None: + await self._refresh(rec) + current = self._quote_from_record(rec) + assert current is not None + if rec.accept_side == side: + return current.model_copy(update={"info": {**current.info, "accept": {"resent": False, + "client_order_id": accept.client_order_id or accept_key(accept.quote_id)}}}) + raise OrderRejected(f"polymarket: quote {accept.quote_id} was already accepted on the {rec.accept_side} side", + reason="already_accepted", info=current.info) + wanted = Side.BUY if rec.direction == "BUY" else Side.SELL + if accept.outcome != "yes" or side != wanted: + raise InvalidOrder( + f"polymarket: RFQ {rec.rfq_id} asked to {wanted.value} the combo's YES; accept it with " + f"outcome='yes', action='{wanted.value}'") + quote = self._quote_from_record(rec) + assert quote is not None + if quote.status != QuoteStatus.OPEN: + raise OrderRejected(f"polymarket: quote {quote.id} is {quote.status.value}, not open " + f"(the acceptance window ends at expires_at)", reason="quote_not_active", info=quote.info) + price = quote.price_for(accept.outcome, accept.action) + maker_amount, taker_amount = int(quote_raw["maker_amount_e6"]), int(quote_raw["taker_amount_e6"]) + order_side = v3.BUY if wanted == Side.BUY else v3.SELL + # The order's own price, to the base unit (its amounts are rounded to + # whole base units, so it differs from the blended price below 1e-6). + implied = v3.implied_price(order_side, maker_amount, taker_amount).quantize(Decimal("0.000001"), ROUND_HALF_UP) + worse = max(price, implied) if wanted == Side.BUY else min(price, implied) # type: ignore[type-var] + if accept.refuses(worse): + raise InvalidOrder( + f"polymarket: quote {quote.id} {wanted.value}s YES at {worse} (blended {price}, order {implied}), " + f"worse than the worst price {accept.worst_price}") + signer, maker, kind = self._rfq_identity() + token = str((rec.echoed or {}).get("yes_position_id") or "") + if not token: + raise ExchangeError(f"polymarket: RFQ {rec.rfq_id} answered with no combo YES position id", body=rec.response) + order = v3.build_order( + maker=maker, signer=signer, token_id=token, maker_amount=maker_amount, taker_amount=taker_amount, + side=order_side, signature_type=kind, timestamp_s=self._now_s(), salt=sig.new_salt(), + ) + body = {"quote_id": accept.quote_id, "signed_order": v3.signed_order(self.signer, order)} + rec.accept_body, rec.accept_side, rec.accepted_at = body, side, _now_ms() + rec.order_hash = v3.order_hash(order) + raw = await self._gateway(self.requester, "POST", f"{REQUESTS_PATH}/{rec.rfq_id}/accept", body=body, + kind="write", priority=Priority.HIGH) + if isinstance(raw, dict) and raw.get("status"): + rec.status = str(raw["status"]) + rec.last = {**rec.last, **raw} + echoed = raw.get("taker_order_hash") + if echoed and str(echoed).lower() != rec.order_hash.lower(): + log.warning("polymarket: RFQ %s taker_order_hash %s is not our Exchange v3 order hash %s", + rec.rfq_id, echoed, rec.order_hash) + else: + rec.status = "AWAITING_MAKER_CONFIRMATION" + result = self._quote_from_record(rec) + assert result is not None + return result.model_copy(update={"info": {**result.info, "accept": { + "resent": True, "client_order_id": accept.client_order_id or accept_key(accept.quote_id), + "order": order, "response": raw}}}) + + # -- RFQ trades as orders and fills --------------------------------------- + + async def fetch_rfq_order(self, order_id: str) -> Order: + """The synthetic order of an RFQ trade (`rfq:`, ours as the + requester, or `rfq::`, ours as a quoter).""" + parsed = parse_rfq_order_id(order_id) + if parsed is None: + raise OrderNotFound(f"polymarket: {order_id} is not an RFQ order id") + rfq_id, quote_id = parsed + if quote_id is not None: + maker = self._maker.get(quote_id) + if maker is None: + raise OrderNotFound(f"polymarket: no quote {quote_id} of ours on RFQ {rfq_id} in this process") + return self._maker_order(maker) + try: + rec = await self._refresh(self._record(rfq_id)) + except RfqNotFound as exc: + raise OrderNotFound(str(exc)) from None + if rec.accept_body is None or not rec.quote: + raise OrderNotFound(f"polymarket: RFQ {rfq_id} was never accepted; it made no order") + return self._taker_order(rec) + + def _taker_order(self, rec: RfqRecord) -> Order: + quote = rec.quote or {} + buy = rec.direction == "BUY" + wire = rec.wire_status() + maker_amount, taker_amount = int(quote["maker_amount_e6"]), int(quote["taker_amount_e6"]) + shares = from_e6(taker_amount if buy else maker_amount) + assert shares is not None + filled = shares if wire in DONE else Decimal(0) + price = from_e6(quote.get("blended_price_e6")) + fee = self._taker_fee(rec) + status = (OrderStatus.CLOSED if wire in DONE else OrderStatus.REJECTED if wire in NO_TRADE + else OrderStatus.PENDING) + return Order( + id=taker_order_id(rec.rfq_id), venue=VENUE, account=self.account, + market_id=(rec.combo.market_id if rec.combo else provisional_combo_id(rec.request["leg_position_ids"])), + side=Side.BUY if buy else Side.SELL, type=OrderType.RFQ_TAKER, time_in_force=TimeInForce.FOK, + status=status, price=price, quantity=shares, filled=filled, + average_price=price if filled else None, fee=fee if filled else None, fee_currency="pUSD", + outcome="yes", action="buy" if buy else "sell", + created_at=rec.accepted_at, updated_at=_now_ms(), + info={"rfq_id": rec.rfq_id, "status": wire, "order_hash": rec.order_hash, + **({"tx_hash": rec.last.get("tx_hash")} if rec.last.get("tx_hash") else {})}, + ) + + @staticmethod + def _taker_fee(rec: RfqRecord) -> Decimal | None: + """BUY: what the budget pays beyond the order's collateral; SELL: the + order's gross proceeds less what arrives.""" + quote = rec.quote or {} + try: + if rec.direction == "BUY": + return (Decimal(quote["total_required_e6"]) - Decimal(quote["maker_amount_e6"])) / E6 + return (Decimal(quote["taker_amount_e6"]) - Decimal(quote["net_receive_e6"])) / E6 + except (KeyError, TypeError, ArithmeticError): + return None + + def rfq_fills(self, *, market_id: str | None = None, order_id: str | None = None) -> list[Fill]: + """Fills of RFQ trades this adapter knows of, as the requester or a quoter.""" + fills: list[Fill] = [] + for rec in self._rfq_records.values(): + if rec.accept_body is None or rec.wire_status() not in DONE: + continue + order = self._taker_order(rec) + fills.append(Fill( + id=f"{order.id}:fill", order_id=order.id, venue=VENUE, account=self.account, market_id=order.market_id, + side=order.side, outcome="yes", action=order.action, price=order.price or Decimal(0), + quantity=order.filled, fee=order.fee, fee_currency="pUSD", liquidity=Liquidity.TAKER, + settlement=SettlementState.CONFIRMED, timestamp=rec.accepted_at or _now_ms(), + info={"rfq_id": rec.rfq_id, **({"tx_hash": rec.last.get("tx_hash")} if rec.last.get("tx_hash") else {})}, + )) + for maker in self._maker.values(): + if maker.status != "EXECUTED": + continue + order = self._maker_order(maker) + fills.append(Fill( + id=f"{order.id}:fill", order_id=order.id, venue=VENUE, account=self.account, market_id=order.market_id, + side=order.side, outcome=order.outcome, action=order.action, price=order.price or Decimal(0), + quantity=order.filled, fee=Decimal(0), fee_currency="pUSD", liquidity=Liquidity.MAKER, + settlement=SettlementState.CONFIRMED, timestamp=maker.sent_at, + info={"rfq_id": maker.rfq_id, "quote_id": maker.quote.id, + **({"tx_hash": maker.tx_hash} if maker.tx_hash else {})}, + )) + if market_id is not None: + wanted = ids.qualify(VENUE, market_id) + fills = [f for f in fills if f.market_id == wanted] + if order_id is not None: + fills = [f for f in fills if f.order_id == order_id] + return fills + + # -- quoter --------------------------------------------------------------- + + async def start_rfq_session( + self, *, url: str = QUOTER_WS_URL, connect: Callable[[str], Any] | None = None, + on_rfq: Callable[[Rfq], Awaitable[None] | None] | None = None, + ) -> PolymarketRfqSession: + """Open the quoter WebSocket (auth with the CLOB credentials and the + wallet identity). Quote requests arrive only here, 400 ms before the + competition closes; `on_rfq` sees each one as it lands.""" + await self.ensure_api_credentials() + if self.rfq_session is None: + self.rfq_session = PolymarketRfqSession(self, url=url, connect=connect, on_rfq=on_rfq) + self.rfq_session.start() + return self.rfq_session + + def subscribe_incoming_rfqs(self, listener: Callable[[Rfq], Any]) -> Callable[[], None]: + """Call `listener(rfq)` (sync or async) for every quote request the + quoter session receives, as it lands (the 400 ms window). An async + listener is scheduled, so the socket keeps reading. Starts nothing: + `start_incoming_rfqs()` (or `start_rfq_session()`) opens the session. + Returns the unsubscribe.""" + self._rfq_listeners.append(listener) + return lambda: self._rfq_listeners.remove(listener) if listener in self._rfq_listeners else None + + async def start_incoming_rfqs(self) -> None: + """Open the quoter session if it is not running (the engine calls + this when its incoming feed starts).""" + task = self.rfq_session._task if self.rfq_session is not None else None + if task is None or task.done(): + await self.start_rfq_session() + + def _tell_rfq_listeners(self, rfq: Rfq) -> None: + for listener in list(self._rfq_listeners): + try: + result = listener(rfq) + if asyncio.iscoroutine(result): + task = asyncio.get_running_loop().create_task(result) + self._listener_tasks.add(task) + task.add_done_callback(self._listener_done) + except Exception: + log.exception("polymarket: an incoming-RFQ listener failed") + + def _listener_done(self, task: asyncio.Task) -> None: + self._listener_tasks.discard(task) + if not task.cancelled() and task.exception() is not None: + log.error("polymarket: an incoming-RFQ listener failed", exc_info=task.exception()) + + async def stop_rfq_session(self) -> None: + if self.rfq_session is not None: + await self.rfq_session.close() + self.rfq_session = None + + def _incoming_from(self, message: dict[str, Any]) -> Rfq | None: + rfq_id = str(message.get("rfq_id") or "") + legs = message.get("leg_position_ids") or [] + if not rfq_id or len(legs) < MIN_LEGS: + return None + condition = message.get("condition_id") + try: + combo = self._combo(legs, condition_id=condition, extra={ + k: message[k] for k in ("yes_position_id", "no_position_id", "condition_id") if message.get(k)}) + except (InvalidOrder, ValueError) as exc: + log.warning("polymarket: skipped an RFQ request that does not validate (%s): %r", exc, message) + return None + size = message.get("requested_size") or {} + value = from_e6(size.get("value_e6")) + rfq = Rfq( + id=rfq_id, venue=VENUE, combo_id=combo.market_id, combo=combo, + outcome="yes" if str(message.get("side") or "YES") == "YES" else "no", + action="buy" if message.get("direction") == "BUY" else "sell", + amount=value if size.get("unit") == "notional" else None, + quantity=value if size.get("unit") == "shares" else None, + status=RfqStatus.OPEN, mine=False, created_at=_now_ms(), + expires_at=int(message["submission_deadline"]) if message.get("submission_deadline") else None, + info=message, + ) + self._incoming[rfq_id] = (rfq, message) + return rfq + + async def fetch_incoming_rfqs( + self, *, market_id: str | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Rfq]: + """Quote requests the quoter session received whose submission window + is still open. Empty, with a warning, when no session is running.""" + if self.rfq_session is None: + if not getattr(self, "_warned_no_session", False): + self._warned_no_session = True + log.warning("polymarket: no RFQ quoter session; call start_rfq_session() to receive quote requests") + return Page([], next_cursor=None) + now = _now_ms() + for rfq_id in [k for k, (r, _) in self._incoming.items() if r.expires_at is not None and r.expires_at + 60_000 < now]: + self._incoming.pop(rfq_id, None) + rows = [r for r, _ in self._incoming.values() if r.expires_at is None or r.expires_at > now] + if market_id is not None: + rows = [r for r in rows if r.combo_id == ids.qualify(VENUE, market_id)] + return Page(rows[:limit] if limit else rows, next_cursor=None) + + async def send_quote(self, quote: MakerQuote) -> RfqQuote: + """`POST /v1/maker/quotes`: price the request's one side and sign the + Exchange v3 order that fills it from collateral -- to fill a BUY of + YES we sell YES at `ask` by buying NO at `1 - ask`; to fill a SELL we + buy YES at `bid`. `quantity` (shares) defaults to the request's full + size; `expires_at` is sent as `valid_until`. The quote id is ours + (`quote_` + 32 hex), derived from the engine's key.""" + held = self._incoming.get(quote.rfq_id) + if held is None: + raise InvalidOrder(f"polymarket: RFQ {quote.rfq_id} is not one the quoter session received") + rfq, message = held + if quote.rest_remainder or quote.post_only: + raise InvalidOrder("polymarket: a combo quote has no rest_remainder or post_only") + direction = str(message.get("direction")) + price = quote.ask if direction == "BUY" else quote.bid + if price is None: + raise InvalidOrder(f"polymarket: RFQ {quote.rfq_id} asks to {direction.lower()} YES; quote the " + f"{'ask' if direction == 'BUY' else 'bid'}") + if direction == "BUY" and price > BUY_PRICE_CEILING: + raise InvalidOrder(f"polymarket: a BUY quote above {BUY_PRICE_CEILING} is refused by the venue " + "(QUOTED_PRICE_ABOVE_SAFETY_THRESHOLD)") + price_e6 = to_e6(price, "price") + size = message.get("requested_size") or {} + full = v3.quote_size(str(size.get("unit")), int(size.get("value_e6") or 0), price_e6) + size_e6 = to_e6(quote.quantity, "quantity") if quote.quantity is not None else full + if size_e6 > full: + raise InvalidOrder(f"polymarket: {Decimal(size_e6) / E6} shares is more than the request ({Decimal(full) / E6})") + token_side, maker_amount, taker_amount = v3.maker_quote_amounts(direction, price_e6, size_e6) + token = str(message.get("no_position_id" if token_side == "no" else "yes_position_id") or "") + if not token: + raise InvalidOrder(f"polymarket: RFQ {quote.rfq_id} names no {token_side.upper()} position id") + signer, maker, kind = self._rfq_identity() + order = v3.build_order(maker=maker, signer=signer, token_id=token, maker_amount=maker_amount, + taker_amount=taker_amount, side=v3.BUY, signature_type=kind, + timestamp_s=self._now_s(), salt=sig.new_salt()) + quote_id = quote_id_for(quote.client_order_id) + body: dict[str, Any] = { + "quote_id": quote_id, "rfq_id": quote.rfq_id, "signer_address": signer, "maker_address": maker, + "signature_type": kind, "price_e6": str(price_e6), "size_e6": str(size_e6), + "signed_order": v3.signed_order(self.signer, order), + } + if quote.expires_at is not None: + body["valid_until"] = int(quote.expires_at) + raw = await self._gateway(self.rfq_api, "POST", "/v1/maker/quotes", body=body, kind="write") + shares = Decimal(size_e6) / E6 + result = RfqQuote( + id=quote_id, rfq_id=quote.rfq_id, venue=VENUE, account=quote.account or self.account, + market_id=rfq.combo_id, + ask=price if direction == "BUY" else None, bid=price if direction == "SELL" else None, + ask_quantity=shares if direction == "BUY" else None, bid_quantity=shares if direction == "SELL" else None, + status=QuoteStatus.OPEN, mine=True, expires_at=quote.expires_at, created_at=_now_ms(), + info={"request": body, "response": raw, "client_order_id": quote.client_order_id, + "order_hash": v3.order_hash(order)}, + ) + self._maker[quote_id] = MakerRecord(quote=result, rfq_id=quote.rfq_id, request=body, sent_at=_now_ms(), + detail={"snapshot": raw} if raw else {}) + return result + + async def replace_quote(self, quote_id: str, quote: MakerQuote) -> RfqQuote: + """The venue has no replace: cancel the old quote, then send the new + one (a new id). A cancel the venue no longer accepts is ignored.""" + try: + await self.cancel_quote(quote_id, rfq_id=quote.rfq_id) + except (RfqNotFound, OrderRejected) as exc: + log.info("polymarket: replacing quote %s, its cancel was refused (%s); sending the new one", quote_id, exc) + new = await self.send_quote(quote) + return new.model_copy(update={"info": {**new.info, "replaces": quote_id}}) + + async def cancel_quote(self, quote_id: str, *, rfq_id: str | None = None) -> None: + """Withdraw a quote (`POST /v1/maker/quotes/cancel`), or, while its + last look is open, decline it (`POST /v1/maker/confirmations` with + `DECLINE`). A cancel acknowledged after selection may not withdraw it.""" + held = self._maker.get(quote_id) + rfq_id = rfq_id or (held.rfq_id if held else None) + if not rfq_id: + raise InvalidOrder("polymarket: a quote is cancelled under its RFQ; give rfq_id") + signer, maker, kind = self._rfq_identity() + ident = {"rfq_id": rfq_id, "quote_id": quote_id, "signer_address": signer, "maker_address": maker, + "signature_type": kind} + if held is not None and held.status == "ACCEPTED": + raw = await self._gateway(self.rfq_api, "POST", "/v1/maker/confirmations", body={**ident, "decision": "DECLINE"}, + kind="write", priority=Priority.HIGH) + else: + raw = await self._gateway(self.rfq_api, "POST", "/v1/maker/quotes/cancel", body=ident, kind="write", + priority=Priority.HIGH) + if held is not None: + held.status = "CANCELED" + held.detail["cancel"] = raw + + async def confirm_quote(self, quote: RfqQuote) -> RfqQuote: + """The last look: `POST /v1/maker/confirmations` with `CONFIRM`. + Answers `execution` when ours completed the bundle (the trade goes + to the chain), else a `snapshot` still waiting on other makers.""" + held = self._maker.get(quote.id) + signer, maker, kind = self._rfq_identity() + body = {"rfq_id": quote.rfq_id, "quote_id": quote.id, "signer_address": signer, "maker_address": maker, + "signature_type": kind, "decision": "CONFIRM"} + raw = await self._gateway(self.rfq_api, "POST", "/v1/maker/confirmations", body=body, kind="write", + priority=Priority.HIGH) + if held is not None: + held.status = "CONFIRMED" + held.detail["confirm"] = raw + return self._maker_quote(held) + return quote.model_copy(update={"status": QuoteStatus.CONFIRMED, "info": {**quote.info, "confirm": raw}}) + + async def fetch_my_quotes( + self, *, rfq_id: str | None = None, status: QuoteStatus | str | None = None, + ) -> list[RfqQuote]: + """Our quotes in this process, as the session and the REST answers left them.""" + quotes = [self._maker_quote(m) for m in self._maker.values() if rfq_id is None or m.rfq_id == rfq_id] + if status is not None: + quotes = [q for q in quotes if q.status == QuoteStatus(status)] + return quotes + + def _maker_quote(self, held: MakerRecord) -> RfqQuote: + if held.status == "OPEN" and (_now_ms() - held.sent_at) > MAKER_QUOTE_STALE_S * 1000: + held.status = "EXPIRED" + held.detail["inferred"] = "not selected within the competition and acceptance windows" + status = MAKER_STATUS[held.status] + # The requester's side: it buys YES when we quoted an ask. + accepted = None if held.status in ("OPEN", "EXPIRED") else (Side.BUY if held.quote.ask is not None else Side.SELL) + update: dict[str, Any] = {"status": status, "accepted_side": accepted, + "info": {**held.quote.info, **held.detail, "status": held.status}} + if held.fill_size_e6 is not None: + shares = Decimal(held.fill_size_e6) / E6 + update["ask_quantity" if held.quote.ask is not None else "bid_quantity"] = shares + if status == QuoteStatus.EXECUTED: + update["maker_order_id"] = maker_order_id(held.rfq_id, held.quote.id) + if held.detail.get("confirm_by"): + update["confirm_by"] = int(held.detail["confirm_by"]) + return held.quote.model_copy(update=update) + + def _maker_order(self, held: MakerRecord) -> Order: + quote = self._maker_quote(held) + buy_request = quote.ask is not None # the requester buys YES; we sell YES (buying NO) + price = quote.ask if buy_request else quote.bid + size = Decimal(held.fill_size_e6 or int(held.request["size_e6"])) / E6 + filled = size if held.status == "EXECUTED" else Decimal(0) + status = (OrderStatus.CLOSED if held.status == "EXECUTED" + else OrderStatus.REJECTED if held.status in ("FAILED", "CANCELED", "EXPIRED") else OrderStatus.PENDING) + return Order( + id=maker_order_id(held.rfq_id, held.quote.id), venue=VENUE, account=self.account, + market_id=quote.market_id or provisional_combo_id(held.request.get("leg_position_ids") or []), + side=Side.SELL if buy_request else Side.BUY, type=OrderType.RFQ_MAKER, time_in_force=TimeInForce.FOK, + status=status, price=price, quantity=size, filled=filled, average_price=price if filled else None, + outcome="no" if buy_request else "yes", action="buy", fee=Decimal(0) if filled else None, + fee_currency="pUSD", created_at=held.sent_at, updated_at=_now_ms(), + info={"rfq_id": held.rfq_id, "quote_id": held.quote.id, "status": held.status, + **({"tx_hash": held.tx_hash} if held.tx_hash else {})}, + ) + + def _makers_on(self, rfq_id: str) -> list[MakerRecord]: + return [m for m in self._maker.values() if m.rfq_id == rfq_id] + + def _maker_event(self, message: dict[str, Any], status: str) -> None: + held = self._maker.get(str(message.get("quote_id") or "")) + if held is None or held.status in ("EXECUTED", "FAILED"): + return + held.status = status + if message.get("fill_size_e6"): + held.fill_size_e6 = int(message["fill_size_e6"]) + if message.get("confirm_by"): + held.detail["confirm_by"] = int(message["confirm_by"]) + held.detail["confirmation_request"] = message + + def _maker_execution(self, message: dict[str, Any]) -> None: + status = EXECUTION_TO_MAKER.get(str(message.get("status") or "")) + for held in self._makers_on(str(message.get("rfq_id") or "")): + # Sent only for a quote of ours that was selected, so it overrides + # an inferred `lost` or `expired`; a final status stays final, and + # a quote we cancelled (a replaced one on the same RFQ) is left out. + if status is None or held.status in ("EXECUTED", "FAILED") or "cancel" in held.detail: + continue + held.status = status + held.tx_hash = message.get("tx_hash") or held.tx_hash + held.detail["execution"] = message + + def _maker_trade(self, message: dict[str, Any]) -> None: + """A trade broadcast: an RFQ we quoted traded. Without an execution + update of ours it went to another maker.""" + for held in self._makers_on(str(message.get("rfq_id") or "")): + if held.status == "OPEN": + held.status = "CANCELED" + held.detail["lost"] = "the RFQ traded with another maker" + + def _maker_error(self, message: dict[str, Any]) -> None: + held = self._maker.get(str(message.get("quote_id") or "")) + if held is not None and message.get("request_type") == "RFQ_QUOTE" and held.status == "OPEN": + held.status = "FAILED" + held.detail["error"] = message + + +def accept_key(quote_id: str) -> str: + """The engine's idempotency key for an accept (docs/rfq-combo.md §5).""" + return f"rfq-accept:{VENUE}:{quote_id}" diff --git a/synpath/trading/polymarket_signing.py b/synpath/trading/polymarket_signing.py index cd651b0..078ac0f 100644 --- a/synpath/trading/polymarket_signing.py +++ b/synpath/trading/polymarket_signing.py @@ -41,13 +41,13 @@ PUSD = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" CTF_COLLATERAL_ADAPTER = "0xAdA100Db00Ca00073811820692005400218FcE1f" NEG_RISK_CTF_COLLATERAL_ADAPTER = "0xadA2005600Dec949baf300f4C6120000bDB6eAab" +# The CLOB checks the wallet's pUSD allowance to this adapter before it accepts +# an order on a neg-risk market (several outcomes of one event), as well as the +# neg-risk exchange's (seen 2026-10-13: an order refused with `spender: +# 0xd91E…, allowance: 0`). NEG_RISK_ADAPTER = "0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296" -"""The CLOB checks the wallet's pUSD allowance to this adapter before it accepts -an order on a neg-risk market (several outcomes of one event), as well as the -neg-risk exchange's (seen 2026-10-13: an order refused with `spender: -0xd91E…, allowance: 0`).""" +# Addresses from the venue's contract page, Polygon mainnet, CLOB V2. DEPOSIT_WALLET_FACTORY = "0x00000000000Fb5C9ADea0298D729A0CB3823Cc07" -"""Addresses from the venue's contract page, Polygon mainnet, CLOB V2.""" ZERO_BYTES32 = "0x" + "00" * 32 MAX_UINT256 = 2**256 - 1 @@ -84,8 +84,8 @@ EOA, POLY_PROXY, POLY_GNOSIS_SAFE, DEPOSIT_WALLET = 0, 1, 2, 3 +# pUSD and outcome tokens both carry six decimals. SCALE = Decimal(10) ** 6 -"""pUSD and outcome tokens both carry six decimals.""" def _eth(): @@ -214,6 +214,8 @@ class RoundConfig: amount: int +# The venue's precision table: decimals for price, share size and the +# USD amount at each tick size. ROUNDING: dict[str, RoundConfig] = { "0.1": RoundConfig(price=1, size=2, amount=3), "0.01": RoundConfig(price=2, size=2, amount=4), @@ -222,8 +224,6 @@ class RoundConfig: "0.001": RoundConfig(price=3, size=2, amount=5), "0.0001": RoundConfig(price=4, size=2, amount=6), } -"""The venue's precision table: decimals for price, share size and the -USD amount at each tick size.""" def round_config(tick: Decimal) -> RoundConfig: diff --git a/synpath/trading/polymarket_us.py b/synpath/trading/polymarket_us.py index 9514bfa..48c8e04 100644 --- a/synpath/trading/polymarket_us.py +++ b/synpath/trading/polymarket_us.py @@ -22,14 +22,15 @@ Batched cancels and modifies echo the ids sent, which the venue says is not a confirmation. -**No client order id, no reduce-only.** The venue assigns order ids; a -`client_order_id` is kept on the returned `Order` for the caller's own -records and is not sent. Reduce-only is refused: the venue offers -close-position instead. +**No client order id.** The venue assigns order ids; a `client_order_id` +is kept on the returned `Order` for the caller's own records and is not +sent. The venue nets YES and NO, so Sell YES and Buy NO are the same order +on the YES leg; to flatten a position the venue offers close-position. """ from __future__ import annotations import base64 +import logging import time from dataclasses import dataclass, field from datetime import datetime, timezone @@ -43,26 +44,46 @@ from ..types import Page from .base import TradingExchange from .credentials import PolymarketUSCredentials -from .errors import InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected +from .errors import InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected, RfqNotFound from .limiter import BudgetLimiter, Priority from .money import D, complement, validate_quantity, validate_price from .types import ( Account, Balance, EditRequest, FeeEstimate, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Precision, Settlement, Side, TimeInForce, VENUE_ORDER_TYPES, + AcceptQuote, Combo, ComboLeg, ComboMarket, MakerQuote, QuoteStatus, Rfq, RfqCloseReason, RfqQuote, RfqStatus, ) +log = logging.getLogger("synpath.trading.polymarket_us") + VENUE = "polymarket_us" API_URL = "https://api.polymarket.us" GATEWAY_URL = "https://gateway.polymarket.us" BATCH = 20 PRICE_FLOOR = Decimal("0.01") +# The exchange's absolute price limits. A price outside them still gets an +# order id and is then rejected, so it is refused here first. PRICE_CEILING = Decimal("0.99") -"""The exchange's absolute price limits. A price outside them still gets an -order id and is then rejected, so it is refused here first.""" MARKET_TTL_S = 300.0 +# The venue's per-endpoint limit on its RFQ list and create calls. +RFQ_PACE_PER_SECOND = 1.0 +# Endpoints the venue limits to one request a second, each to its pace +# group (docs/rfq-combo.md, Polymarket US): `GET /v1/rfqs`, `GET +# /v1/rfqs/quotes`, and `POST /v1/rfqs` with `POST /v1/combos`, which share +# one budget. Exact paths: `/v1/rfqs/user-id` and the per-id writes (cancel, +# accept, confirm) only meet the shared limiter. +PACED_ENDPOINTS: dict[tuple[str, str], str] = { + ("GET", "/v1/rfqs"): "rfqs", + ("GET", "/v1/rfqs/quotes"): "rfq_quotes", + ("POST", "/v1/rfqs"): "rfq_create", + ("POST", "/v1/combos"): "rfq_create", +} +# How long a paced call may queue behind others in its group before it is +# refused (`RateBudgetExceeded`) rather than sent late. +PACE_MAX_WAIT_S = 15.0 + # --------------------------------------------------------------------------- # Market rules @@ -158,8 +179,6 @@ def translate_order(request: OrderRequest, rules: MarketRules) -> dict[str, Any] "polymarket_us: a price is required -- a market order is sent as an immediate limit at the " "protection price you give" ) - if request.reduce_only: - raise InvalidOrder("polymarket_us: the venue has no reduce-only flag; use close_position") slug = slug_of(request.market_id) wire_price = check_prices(D(request.price), rules) quantity = validate_quantity(D(request.quantity), rules.precision) @@ -196,6 +215,8 @@ def translate_order(request: OrderRequest, rules: MarketRules) -> dict[str, Any] # Normalizers # --------------------------------------------------------------------------- +# `REPLACED` is the superseded half of a cancel-replace; it will not trade +# again, so it reads as cancelled with the native state kept in `info`. STATE = { "ORDER_STATE_PENDING_NEW": OrderStatus.PENDING, "ORDER_STATE_PENDING_RISK": OrderStatus.PENDING, @@ -209,8 +230,6 @@ def translate_order(request: OrderRequest, rules: MarketRules) -> dict[str, Any] "ORDER_STATE_REJECTED": OrderStatus.REJECTED, "ORDER_STATE_EXPIRED": OrderStatus.EXPIRED, } -"""`REPLACED` is the superseded half of a cancel-replace; it will not trade -again, so it reads as cancelled with the native state kept in `info`.""" INTENT = { "ORDER_INTENT_BUY_LONG": ("yes", Side.BUY), @@ -378,6 +397,265 @@ def error_of(exc: ExchangeError) -> ExchangeError: return exc +# --------------------------------------------------------------------------- +# Combos and RFQ (docs/rfq-combo.md) +# --------------------------------------------------------------------------- +# +# Sources: https://docs.polymarket.us/api-reference/combos/overview.md, +# /api-reference/rfqs/overview.md, /trader-guide/combos.md, +# /partners/orders/combos-takers.md and the OpenAPI schemas +# /api-reference/oapi-schemas/{combos,rfqs}-schema.json. +# +# A combo is an instrument with its own symbol (`caoc-...`, `crossd-...`); +# the symbol is a market slug like any other here, so its Synpath id is +# `polymarket_us:` and its orders and positions use that slug. +# +# Sides are the requester's, on the YES (long) leg, and unambiguous: +# `buyPrice` is "the price offered for a requester BUY order", `sellPrice` +# "for a requester SELL order", and `acceptedSide` is the requester's side +# (`SIDE_BUY` takes `buyPrice` and `buyQtyDecimal`, the maker sells). Both +# prices are YES prices: a requester sell costs `1 - price` a contract. +# A price of "0" marks a side the maker declined. + +MAX_COMBO_LEGS = 50 +# The documented combo symbol prefixes. The venue says to treat symbols as +# opaque; this is only the fallback when an RFQ carries no `comboLegs`. +COMBO_PREFIXES = ("caoc-", "crossd-") + +SIDE_TO_WIRE = {Side.BUY: "SIDE_BUY", Side.SELL: "SIDE_SELL"} +SIDE_FROM_WIRE = {wire: side for side, wire in SIDE_TO_WIRE.items()} + +RFQ_STATUS_TO_WIRE = {RfqStatus.OPEN: "RFQ_STATUS_OPEN", RfqStatus.CLOSED: "RFQ_STATUS_CLOSED"} +RFQ_STATUS_FROM_WIRE = {wire: status for status, wire in RFQ_STATUS_TO_WIRE.items()} + +QUOTE_STATUS_FROM_WIRE = { + "QUOTE_STATUS_ACTIVE": QuoteStatus.OPEN, + "QUOTE_STATUS_ACCEPTED": QuoteStatus.ACCEPTED, + "QUOTE_STATUS_CONFIRMED": QuoteStatus.CONFIRMED, + "QUOTE_STATUS_EXECUTED": QuoteStatus.EXECUTED, + # Withdrawn, replaced, or declined during the last look: the venue has + # no expired or failed status, and none of these traded. + "QUOTE_STATUS_DELETED": QuoteStatus.CANCELED, +} +QUOTE_STATUS_TO_WIRE = {status: wire for wire, status in QUOTE_STATUS_FROM_WIRE.items()} + +# Every other instrument state (pre-open, suspended, halted, closed, +# expired, terminated...) reads as `closed`; the native state stays in `info`. +# Settlement is read on the symbol, not from this state. +COMBO_STATE = {"INSTRUMENT_STATE_OPEN": "open"} + + +def decimal_text(value: Decimal) -> str: + """An exact decimal string, as the RFQ API wants prices and sizes: no + exponent, no trailing zeros (`0.6150` -> `"0.615"`, `10` -> `"10"`).""" + return format(D(value).normalize(), "f") + + +def _decimal(value: Any) -> Decimal | None: + if isinstance(value, dict): + value = value.get("value") + return D(str(value)) if value not in (None, "") else None + + +def _positive_or_none(value: Any) -> Decimal | None: + """A price or quantity, `None` for a declined side ("0") or an absent one.""" + number_ = _decimal(value) + return number_ if number_ is not None and number_ > 0 else None + + +def is_combo_symbol(symbol: str) -> bool: + return symbol.startswith(COMBO_PREFIXES) + + +def leg_to_wire(leg: ComboLeg) -> dict[str, str]: + """`outcome=yes` buys the leg (`SIDE_BUY`); `no` sells it (`SIDE_SELL`).""" + return {"symbol": slug_of(leg.market_id), "side": "SIDE_BUY" if leg.outcome == "yes" else "SIDE_SELL"} + + +def leg_of(raw: dict[str, Any]) -> ComboLeg: + return ComboLeg( + market_id=ids.qualify(VENUE, str(raw.get("symbol") or "")), + outcome="yes" if raw.get("side") == "SIDE_BUY" else "no", + info=raw, + ) + + +def combo_of(raw: dict[str, Any]) -> Combo: + """A `v1ComboInstrument`.""" + state = str(raw.get("state") or "") + return Combo( + market_id=ids.qualify(VENUE, str(raw.get("id") or "")), + venue=VENUE, + legs=[leg_of(leg) for leg in raw.get("legs") or []], + status=COMBO_STATE.get(state, "closed") if state else None, + created_at=parse_ts(raw.get("createdTime")), + info=raw, + ) + + +# Events read per page of `fetch_combo_eligible_markets` when no limit is given. +COMBO_EVENTS_PAGE = 50 + + +def combo_markets_of(event: dict[str, Any]) -> list[ComboMarket]: + """The `comboEnabled` markets of one gateway event (`GET /v1/events`, + markets nested). The venue's own status rides along; whether a leg is + tradable now is the venue's answer to `POST /v1/combos`.""" + slug = str(event.get("slug") or "") + tags = [str(t.get("slug") or t.get("label") or "") if isinstance(t, dict) else str(t) + for t in event.get("tags") or []] + out: list[ComboMarket] = [] + for market in event.get("markets") or []: + if not market.get("comboEnabled"): + continue + symbol = str(market.get("slug") or "") + if not symbol: + continue + out.append(ComboMarket( + market_id=ids.qualify(VENUE, symbol), + venue=VENUE, + title=market.get("question") or symbol, + event_id=ids.qualify(VENUE, slug) if slug else None, + event_title=event.get("title") or None, + min_legs=2, + max_legs=MAX_COMBO_LEGS, + status=market.get("status") or None, + tags=[t for t in tags if t], + info={**market, "event": {k: v for k, v in event.items() if k != "markets"}}, + )) + return out + + +def rfq_of(raw: dict[str, Any], *, me: str | None = None, mine: bool | None = None, account: Account | None = None) -> Rfq: + """A `v1RFQ`. `mine` is whether `rfqCreatorUserId` is `me`, this + participant's RFQ user id, unless the caller already knows.""" + symbol = str(raw.get("symbol") or "") + instrument = ids.qualify(VENUE, symbol) + legs = raw.get("comboLegs") or [] + combo = None + if len(legs) >= 2: + combo = Combo(market_id=instrument, venue=VENUE, legs=[leg_of(leg) for leg in legs], info={"comboLegs": legs}) + on_combo = combo is not None or is_combo_symbol(symbol) + if mine is None: + mine = me is not None and raw.get("rfqCreatorUserId") == me + status = RFQ_STATUS_FROM_WIRE.get(str(raw.get("status") or ""), RfqStatus.OPEN) + return Rfq( + id=str(raw.get("id") or ""), + venue=VENUE, + account=account if mine else None, + market_id=None if on_combo else instrument, + combo_id=instrument if on_combo else None, + combo=combo, + quantity=_positive_or_none(raw.get("qtyDecimal")), + amount=_positive_or_none(raw.get("cashOrderQty")), + rest_remainder=bool(raw.get("restRemainder")), + status=status, + mine=mine, + created_at=parse_ts(raw.get("createdTime")), + updated_at=parse_ts(raw.get("updatedTime")), + info=raw, + ) + + +def quote_of(raw: dict[str, Any], *, me: str | None = None, mine: bool | None = None, account: Account | None = None) -> RfqQuote: + """A `v1Quote` on the YES leg: `ask` is `buyPrice` (what the requester + buys at), `bid` is `sellPrice` (what it sells at); a "0" side is `None`. + `accepted_side` is the requester's side as the venue reports it.""" + if mine is None: + mine = me is not None and raw.get("creatorRfqUserId") == me + accepted = SIDE_FROM_WIRE.get(str(raw.get("acceptedSide") or "")) + symbol = raw.get("symbol") + quote = RfqQuote.from_venue(dict( + id=str(raw.get("id") or ""), + rfq_id=str(raw.get("rfqId") or ""), + venue=VENUE, + account=account if mine else None, + market_id=ids.qualify(VENUE, str(symbol)) if symbol else None, + bid=_positive_or_none(raw.get("sellPrice")), + ask=_positive_or_none(raw.get("buyPrice")), + bid_quantity=_positive_or_none(raw.get("sellQtyDecimal")), + ask_quantity=_positive_or_none(raw.get("buyQtyDecimal")), + status=QUOTE_STATUS_FROM_WIRE.get(str(raw.get("status") or ""), QuoteStatus.OPEN), + accepted_side=accepted, + confirm_by=parse_ts(raw.get("confirmationDeadline")), + execute_by=parse_ts(raw.get("executionDeadline")), + taker_order_id=raw.get("rfqCreatorOrderId") or None, + maker_order_id=raw.get("creatorOrderId") or None, + mine=mine, + created_at=parse_ts(raw.get("createdTime")), + updated_at=parse_ts(raw.get("updatedTime")), + info=raw, + )) + if quote.crossed: + log.warning("polymarket_us: quote %s on RFQ %s is crossed (bid %s above ask %s); kept and flagged", + quote.id, quote.rfq_id, quote.bid, quote.ask) + return quote + + +def tolerant(rows: Any, build: Any, what: str) -> list[Any]: + """Normalise a venue list row by row: a row the model refuses is logged + and skipped, so one bad record does not break the whole list.""" + out = [] + for row in rows or []: + try: + out.append(build(row)) + except (ValueError, TypeError) as exc: + log.warning("polymarket_us: skipped a %s the venue sent that does not validate (%s): %r", + what, exc, row) + return out + + +def translate_rfq( + instrument_id: str, *, quantity: Decimal | None, amount: Decimal | None, rest_remainder: bool, +) -> dict[str, Any]: + """The `POST /v1/rfqs` body: `qtyDecimal` for contracts, `cashOrderQty` + for money (the venue sizes each quoted side to it, taker fee included).""" + if (quantity is None) == (amount is None): + raise InvalidOrder("polymarket_us: size an RFQ by quantity (contracts) or amount (USD), exactly one") + body: dict[str, Any] = {"symbol": slug_of(instrument_id), "restRemainder": bool(rest_remainder)} + if quantity is not None: + if D(quantity) <= 0: + raise InvalidOrder(f"polymarket_us: quantity must be positive, got {quantity}") + body["qtyDecimal"] = decimal_text(quantity) + else: + if D(amount) <= 0: + raise InvalidOrder(f"polymarket_us: amount must be positive, got {amount}") + body["cashOrderQty"] = decimal_text(amount) # type: ignore[arg-type] + return body + + +def translate_quote(quote: MakerQuote) -> dict[str, Any]: + """The `POST /v1/rfqs/quotes` body. `buyPrice` is our ask (the requester + buys, we sell), `sellPrice` our bid; "0" declines a side. The venue takes + no quantity, expiry or client id: sizes come from the RFQ.""" + if quote.quantity is not None: + raise InvalidOrder("polymarket_us: a quote is for the RFQ's full size; the venue takes no quantity") + if quote.expires_at is not None: + raise InvalidOrder("polymarket_us: a quote has no expiry on this venue; cancel it instead") + body: dict[str, Any] = { + "rfqId": quote.rfq_id, + "buyPrice": decimal_text(quote.ask) if quote.ask is not None else "0", + "sellPrice": decimal_text(quote.bid) if quote.bid is not None else "0", + "restRemainder": bool(quote.rest_remainder), + } + if quote.post_only: + body["postOnly"] = True + return body + + +def accept_key(quote_id: str) -> str: + """The engine's idempotency key for an accept (docs/rfq-combo.md §5).""" + return f"rfq-accept:{VENUE}:{quote_id}" + + +def rfq_error_of(exc: ExchangeError) -> ExchangeError: + """`error_of`, except that a 404 names an RFQ, quote or combo.""" + if isinstance(exc, MarketNotFound): + return RfqNotFound(str(exc), body=exc.body, status=exc.status) + return error_of(exc) + + + # --------------------------------------------------------------------------- # Adapter # --------------------------------------------------------------------------- @@ -438,8 +716,12 @@ class PolymarketUSTrading(TradingExchange): "fetch_settlements": True, "fetch_queue_position": False, "fetch_fee_estimate": True, - # RFQ on this venue quotes combos, which synpath does not model yet. + # The old key means the raw Kalshi RFQ methods; this venue answers + # the typed ones below (docs/rfq-combo.md, P2). "rfq": False, + "rfq_taker": True, + "rfq_quoter": True, + "combos": True, "split_merge": False, "watch_orders": False, "watch_my_trades": False, @@ -457,6 +739,7 @@ def __init__( limiter: BudgetLimiter | None = None, timeout: float = 30.0, client: Any = None, + rfq_pace_per_second: float | None = RFQ_PACE_PER_SECOND, ): import httpx @@ -465,15 +748,28 @@ def __init__( self.account = Account(venue=VENUE, name=account_name) # Twenty requests a second per key, shared by every endpoint: one lane. self.limiter = limiter or BudgetLimiter(read_per_second=20, write_per_second=20, burst_seconds=1) + # One request a second on the RFQ lists and creates, per pace group, + # ahead of the shared limiter: the engine's follower polls quotes and + # must not trip the venue's 429s. `None` turns it off. + self.pacers: dict[str, BudgetLimiter] = {} if not rfq_pace_per_second else { + group: BudgetLimiter(read_per_second=rfq_pace_per_second, write_per_second=rfq_pace_per_second, + burst_seconds=1, borrow_seconds=1, max_wait_s=PACE_MAX_WAIT_S) + for group in set(PACED_ENDPOINTS.values()) + } shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True) self.api = AsyncHttpClient(api_url, limiter=None, client=shared, venue=VENUE) self.gateway = AsyncHttpClient(gateway_url, limiter=None, client=shared, venue=VENUE) self._http = shared self._markets: dict[str, MarketRules] = {} + self._rfq_user_id: str | None = None async def _call( self, method: str, path: str, *, params: Any = None, json: Any = None, priority: Priority = Priority.NORMAL, + errors: Any = error_of, ) -> Any: + group = PACED_ENDPOINTS.get((method.upper(), path)) + if group is not None and group in self.pacers: + await self.pacers[group].acquire(cost=1, kind="write", priority=Priority.NORMAL) await self.limiter.acquire(cost=1, kind="write", priority=priority) headers = self.signer.headers(method, path) try: @@ -481,7 +777,10 @@ async def _call( method, path, params=params or None, json=json, headers=headers, ) except ExchangeError as exc: - raise error_of(exc) from None + raise errors(exc) from None + + async def _rfq_call(self, method: str, path: str, **kwargs: Any) -> Any: + return await self._call(method, path, errors=rfq_error_of, **kwargs) # -- market rules --------------------------------------------------------- @@ -701,5 +1000,288 @@ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, q currency="USD", info={"schedule": schedule.model_dump()}, ) + # -- combos --------------------------------------------------------------- + + async def create_combo(self, legs: list[ComboLeg], *, collection_id: str | None = None) -> Combo: + """`POST /v1/combos`: the canonical combo for these legs. The same legs + always give the same symbol, and returning an existing combo does not + use the weekly quota of 1,000 new ones, so this is idempotent. Legs + the venue cannot combine (incompatible, redundant, not tradable) are + `InvalidOrder` with the venue's message. The venue has no + collections, so a `collection_id` is refused before anything is sent.""" + if collection_id is not None: + raise InvalidOrder(f"polymarket_us: combos are not built in collections; drop collection_id " + f"({collection_id!r})") + if not 2 <= len(legs) <= MAX_COMBO_LEGS: + raise InvalidOrder(f"polymarket_us: a combo has 2 to {MAX_COMBO_LEGS} legs, got {len(legs)}") + wire = [leg_to_wire(leg) for leg in legs] + if len({leg["symbol"] for leg in wire}) != len(wire): + raise InvalidOrder("polymarket_us: a combo names each market once") + try: + raw = await self._rfq_call("POST", "/v1/combos", json={"legs": wire}) + except OrderRejected as exc: + if exc.reason == "latency_stopgap": + raise + raise InvalidOrder(str(exc), body=exc.body, status=exc.status) from None + found = (raw or {}).get("combo") + if not found or not found.get("id"): + raise ExchangeError("polymarket_us: create combo returned no combo", body=raw) + return combo_of(found) + + async def fetch_combo_eligible_markets( + self, *, event_id: str | None = None, tag: str | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[ComboMarket]: + """The combo-enabled markets (`comboEnabled`), from the public + gateway's open events (`GET /v1/events`, markets nested), newest + event first. The venue has no combo filter, so `limit` counts the + events read (default 50) and each page holds their combo-enabled + markets, possibly none. `event_id` (`polymarket_us:`) and + `tag` (a tag slug, such as `nfl`) are the venue's own filters. The + cursor is an offset: an event listed mid-walk can repeat a row on the + next page.""" + wanted = limit or COMBO_EVENTS_PAGE + try: + offset = int(cursor) if cursor else 0 + except ValueError: + raise BadRequest(f"polymarket_us: not a cursor from this list: {cursor!r}") from None + raw = await self.gateway.get("/v1/events", { + "limit": wanted, "offset": offset or None, "active": "true", "closed": "false", + "orderBy": "id", "orderDirection": "desc", + "slug": ids.native(VENUE, event_id) if event_id else None, "tagSlug": tag, + }) + events = (raw or {}).get("events") or [] + rows = [row for event in events for row in combo_markets_of(event)] + more = len(events) >= wanted and not event_id + return Page(rows, next_cursor=str(offset + len(events)) if more else None) + + async def fetch_combo(self, combo_id: str) -> Combo: + """`GET /v1/combos?symbol=`, the exact symbol.""" + symbol = slug_of(combo_id) + raw = await self._rfq_call("GET", "/v1/combos", params={"symbol": symbol}) + rows = (raw or {}).get("combos") or [] + if not rows: + raise RfqNotFound(f"polymarket_us: no combo {symbol!r}") + return combo_of(rows[0]) + + async def lookup_combo(self, legs: list[ComboLeg]) -> Combo | None: + raise NotSupported( + "polymarket_us: no read-only lookup by legs; create_combo returns the existing combo for the same " + "legs without using the weekly quota" + ) + + # -- RFQ, both sides ------------------------------------------------------ + + async def fetch_rfq_user_id(self) -> str: + """This participant's pseudonymous RFQ user id (`GET /v1/rfqs/user-id`), + which marks its own RFQs and quotes. Read once and kept.""" + if self._rfq_user_id is None: + raw = await self._rfq_call("GET", "/v1/rfqs/user-id") + self._rfq_user_id = str((raw or {}).get("rfqUserId") or "") or None + return self._rfq_user_id or "" + + async def _rfqs(self, params: dict[str, Any], *, mine: bool | None) -> Page[Rfq]: + me = None if mine is not None else await self.fetch_rfq_user_id() + raw = await self._rfq_call("GET", "/v1/rfqs", params={k: v for k, v in params.items() if v is not None}) + rows = tolerant((raw or {}).get("rfqs"), lambda row: rfq_of(row, me=me, mine=mine, account=self.account), "RFQ") + return Page(rows, next_cursor=(raw or {}).get("cursor") or None) + + async def _quotes(self, params: dict[str, Any], *, mine: bool | None = None) -> list[RfqQuote]: + me = None if mine is not None else await self.fetch_rfq_user_id() + params = {k: v for k, v in params.items() if v is not None} + quotes: list[RfqQuote] = [] + cursor: str | None = None + while True: + raw = await self._rfq_call("GET", "/v1/rfqs/quotes", params={**params, **({"cursor": cursor} if cursor else {})}) + quotes.extend(tolerant((raw or {}).get("quotes"), + lambda row: quote_of(row, me=me, mine=mine, account=self.account), "quote")) + cursor = (raw or {}).get("cursor") or None + if not cursor: + return quotes + + @staticmethod + def _quote_status(status: QuoteStatus | str | None) -> str | None: + if status is None: + return None + wire = QUOTE_STATUS_TO_WIRE.get(QuoteStatus(status)) + if wire is None: + raise BadRequest(f"polymarket_us: quotes have no {QuoteStatus(status).value!r} status on this venue") + return wire + + # -- RFQ, requester (taker) side ------------------------------------------ + + async def create_rfq( + self, instrument_id: str, *, quantity: Decimal | None = None, amount: Decimal | None = None, + outcome: str | None = None, action: str | None = None, rest_remainder: bool = False, + combo: Combo | None = None, + ) -> Rfq: + """`POST /v1/rfqs` on a combo or any open instrument. Two-sided: the + venue takes no side, so `outcome`/`action` are refused. An `amount` + is sent as `cashOrderQty` (USD, the combo taker fee included in it). + Returns the RFQ as sent, `open`; the venue answers with its id only.""" + if outcome is not None or action is not None: + raise InvalidOrder("polymarket_us: an RFQ is two-sided; pick the side when accepting a quote") + body = translate_rfq(instrument_id, quantity=quantity, amount=amount, rest_remainder=rest_remainder) + raw = await self._rfq_call("POST", "/v1/rfqs", json=body) + rfq_id = str((raw or {}).get("rfqId") or "") + if not rfq_id: + raise ExchangeError("polymarket_us: create RFQ returned no id", body=raw) + instrument = ids.qualify(VENUE, body["symbol"]) + on_combo = combo is not None or is_combo_symbol(body["symbol"]) + return Rfq( + id=rfq_id, venue=VENUE, account=self.account, + market_id=None if on_combo else instrument, combo_id=instrument if on_combo else None, combo=combo, + quantity=D(quantity) if quantity is not None else None, amount=D(amount) if amount is not None else None, + rest_remainder=rest_remainder, status=RfqStatus.OPEN, mine=True, created_at=int(time.time() * 1000), + info={"request": body, "response": raw}, + ) + + async def fetch_rfq(self, rfq_id: str) -> Rfq: + page = await self._rfqs({"rfqId": rfq_id}, mine=None) + if not page: + raise RfqNotFound(f"polymarket_us: no RFQ {rfq_id}") + return page[0] + + async def fetch_rfqs( + self, *, status: RfqStatus | str | None = None, market_id: str | None = None, + limit: int | None = None, cursor: str | None = None, + ) -> Page[Rfq]: + """This participant's RFQs (`userFilter=USER_FILTER_SELF`).""" + return await self._rfqs({ + "userFilter": "USER_FILTER_SELF", "status": RFQ_STATUS_TO_WIRE[RfqStatus(status)] if status else None, + "symbol": slug_of(market_id) if market_id else None, "limit": limit, "cursor": cursor, + }, mine=True) + + async def cancel_rfq(self, rfq_id: str) -> None: + """`DELETE /v1/rfqs/{id}`. RFQs on this venue have no expiry, so an + RFQ stays open until it is cancelled or a quote on it is accepted.""" + await self._rfq_call("DELETE", f"/v1/rfqs/{rfq_id}", priority=Priority.HIGH) + + async def fetch_quotes(self, rfq_id: str, *, status: QuoteStatus | str | None = None) -> list[RfqQuote]: + """`GET /v1/rfqs/quotes?rfqId=`: every visible quote on our RFQ, or + our own (with earlier replacements) on someone else's.""" + return await self._quotes({"rfqId": rfq_id, "status": self._quote_status(status), "limit": 100}) + + async def fetch_quote(self, quote_id: str, *, rfq_id: str | None = None) -> RfqQuote: + if not rfq_id: + raise InvalidOrder("polymarket_us: a quote is read under its RFQ; give rfq_id") + rows = [q for q in await self._quotes({"rfqId": rfq_id, "quoteId": quote_id}) if q.id == quote_id] + if not rows: + raise RfqNotFound(f"polymarket_us: no quote {quote_id} on RFQ {rfq_id}") + return rows[0] + + async def accept_quote(self, accept: AcceptQuote) -> RfqQuote: + """`PUT /v1/rfqs/{rfq}/quotes/{q}/accept {acceptedSide}`. + + The quote is read first. `acceptedSide` is the requester's side on + the YES leg: buying YES (or selling NO) is `SIDE_BUY` at `buyPrice`, + selling YES (or buying NO) is `SIDE_SELL` at `sellPrice`. Refused + before anything is sent when that side was declined or is worse than + `worst_price`, and when the quote is not active. + + A retry is safe: a quote already accepted on the same side is + returned as read, without sending again, and the venue derives the + requester's order id (`rfq1.`...) from the quote and side, so it + cannot make a second order either. Returns at once with the quote + `accepted`; `executed` later means the orders were submitted, not + filled (`taker_order_id` is `rfqCreatorOrderId` then). + """ + if not accept.rfq_id: + raise InvalidOrder("polymarket_us: a quote is accepted under its RFQ; give rfq_id") + if accept.quantity is not None or accept.amount is not None: + raise InvalidOrder("polymarket_us: a quote is accepted for its full size; the venue takes no partial size") + side = accept.side + wire_side = SIDE_TO_WIRE[side] + key = accept.client_order_id or accept_key(accept.quote_id) + quote = await self.fetch_quote(accept.quote_id, rfq_id=accept.rfq_id) + if quote.status in (QuoteStatus.ACCEPTED, QuoteStatus.CONFIRMED, QuoteStatus.EXECUTED): + if quote.accepted_side == side: + return quote.model_copy(update={"info": {**quote.info, "accept": {"acceptedSide": wire_side, "client_order_id": key, "resent": False}}}) + raise OrderRejected( + f"polymarket_us: quote {quote.id} was already accepted on the {quote.accepted_side} side", + reason="already_accepted", info=quote.info, + ) + if quote.info.get("status") != "QUOTE_STATUS_ACTIVE": + raise OrderRejected( + f"polymarket_us: quote {quote.id} is {quote.info.get('status') or 'in no state'}, not active", + reason="quote_not_active", info=quote.info, + ) + price = quote.price_for(accept.outcome, accept.action) + if price is None: + raise InvalidOrder(f"polymarket_us: quote {quote.id} declines the {side.value} side") + if accept.refuses(price): + raise InvalidOrder( + f"polymarket_us: quote {quote.id} {side.value}s YES at {price}, worse than the worst price " + f"{accept.worst_price}" + ) + body = {"acceptedSide": wire_side} + raw = await self._rfq_call( + "PUT", f"/v1/rfqs/{accept.rfq_id}/quotes/{accept.quote_id}/accept", json=body, priority=Priority.HIGH, + ) + return quote.model_copy(update={ + "status": QuoteStatus.ACCEPTED, "accepted_side": side, + "info": {**quote.info, "accept": {**body, "client_order_id": key, "response": raw, "resent": True}}, + }) + + # -- RFQ, quoter (maker) side --------------------------------------------- + + async def fetch_incoming_rfqs( + self, *, market_id: str | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Rfq]: + """Open RFQs from other participants (ours are left out).""" + page = await self._rfqs({ + "status": "RFQ_STATUS_OPEN", "symbol": slug_of(market_id) if market_id else None, + "limit": limit, "cursor": cursor, + }, mine=None) + return Page([r for r in page if not r.mine], next_cursor=page.next_cursor) + + async def send_quote(self, quote: MakerQuote) -> RfqQuote: + """`POST /v1/rfqs/quotes`, both sides at once: our `ask` is the + venue's `buyPrice`, our `bid` its `sellPrice`, a missing side "0". + Prices go as exact decimals; the venue checks them against the RFQ's + `tickSize`. Returns the quote as sent, `open`, under the new id.""" + body = translate_quote(quote) + raw = await self._rfq_call("POST", "/v1/rfqs/quotes", json=body) + quote_id = str((raw or {}).get("quoteId") or "") + if not quote_id: + raise ExchangeError("polymarket_us: create quote returned no id", body=raw) + return RfqQuote( + id=quote_id, rfq_id=quote.rfq_id, venue=VENUE, account=quote.account or self.account, + bid=quote.bid, ask=quote.ask, status=QuoteStatus.OPEN, mine=True, created_at=int(time.time() * 1000), + info={"request": body, "response": raw, "client_order_id": quote.client_order_id}, + ) + + async def replace_quote(self, quote_id: str, quote: MakerQuote) -> RfqQuote: + """A new quote on the same RFQ. The venue replaces ours in place and + deletes the old one (`quoteDeleted`); the result has a new id.""" + new = await self.send_quote(quote) + return new.model_copy(update={"info": {**new.info, "replaces": quote_id}}) + + async def cancel_quote(self, quote_id: str, *, rfq_id: str | None = None) -> None: + """`DELETE /v1/rfqs/{rfq}/quotes/{q}`: withdraw a quote, or decline an + accepted one during the last look.""" + if not rfq_id: + raise InvalidOrder("polymarket_us: a quote is deleted under its RFQ; give rfq_id") + await self._rfq_call("DELETE", f"/v1/rfqs/{rfq_id}/quotes/{quote_id}", priority=Priority.HIGH) + + async def confirm_quote(self, quote: RfqQuote) -> RfqQuote: + """`PUT /v1/rfqs/{rfq}/quotes/{q}/confirm`, the maker's last look + (3 s from `confirm_by`). Use the id the venue accepted + (`quoteAccepted.quote.id`). The venue then submits the maker's order, + then the requester's, at `execute_by`.""" + raw = await self._rfq_call( + "PUT", f"/v1/rfqs/{quote.rfq_id}/quotes/{quote.id}/confirm", json={}, priority=Priority.HIGH, + ) + return quote.model_copy(update={"status": QuoteStatus.CONFIRMED, "info": {**quote.info, "confirm": raw}}) + + async def fetch_my_quotes( + self, *, rfq_id: str | None = None, status: QuoteStatus | str | None = None, + ) -> list[RfqQuote]: + """Quotes this participant made: `userFilter=USER_FILTER_SELF`, or on + one RFQ `rfqId` alone, which a maker reads as its own quotes there + (replacements included). The venue takes one of the two filters.""" + scope = {"rfqId": rfq_id} if rfq_id else {"userFilter": "USER_FILTER_SELF"} + return await self._quotes({**scope, "status": self._quote_status(status), "limit": 100}, mine=True) + async def close(self) -> None: await self._http.aclose() diff --git a/synpath/trading/polymarket_us_exchange.py b/synpath/trading/polymarket_us_exchange.py index 1e75b91..9a2f7a7 100644 --- a/synpath/trading/polymarket_us_exchange.py +++ b/synpath/trading/polymarket_us_exchange.py @@ -68,8 +68,8 @@ GATEWAY_URL = "https://gateway.polymarket.us" BATCH = 20 +# Refresh the access token this long before the venue says it expires. TOKEN_MARGIN_S = 30 -"""Refresh the access token this long before the venue says it expires.""" NATIVE_ORDER_TYPES = frozenset({OrderType.LIMIT, OrderType.MARKET, OrderType.STOP_MARKET, OrderType.STOP_LIMIT}) @@ -197,8 +197,6 @@ def translate_order(request: OrderRequest, scale: InstrumentScale, *, account: s ) if request.time_in_force == TimeInForce.DAY: raise InvalidOrder("polymarket_us: 'day' is rewritten to 'gtd' by the engine; the venue's DAY does not cancel at the roll") - if request.reduce_only: - raise InvalidOrder("polymarket_us: the exchange API has no reduce-only flag") symbol = symbol_of(request.market_id) precision = scale.precision needs_price = request.type in (OrderType.LIMIT, OrderType.MARKET, OrderType.STOP_LIMIT) @@ -444,8 +442,8 @@ class PolymarketUSExchangeTrading(TradingExchange): id = VENUE name = "Polymarket US (exchange API)" + # Order types the exchange holds itself, stops included. native_order_types = NATIVE_ORDER_TYPES - """Order types the exchange holds itself, stops included.""" has: dict[str, Capability] = { "create_order": True, "create_orders": True, @@ -465,6 +463,10 @@ class PolymarketUSExchangeTrading(TradingExchange): "fetch_queue_position": False, "fetch_fee_estimate": True, "rfq": False, + # Typed RFQ and combos arrive phase by phase (docs/rfq-combo.md). + "rfq_taker": False, + "rfq_quoter": False, + "combos": False, "split_merge": False, "watch_orders": False, "watch_my_trades": False, diff --git a/synpath/trading/polymarket_v3_signing.py b/synpath/trading/polymarket_v3_signing.py new file mode 100644 index 0000000..db4a073 --- /dev/null +++ b/synpath/trading/polymarket_v3_signing.py @@ -0,0 +1,351 @@ +"""Polymarket Exchange v3 order signing, for combo RFQs. No network in sight. + +Combo trades settle through **Exchange v3** (`0xe3333700...00Aa` on Polygon), +not the CLOB V2 exchanges, so the requester's accept and the quoter's quote +each carry an Exchange v3 order (docs.polymarket.com, /trading/combos/ +requesters and /trading/combos/market-makers). The struct is the same +eleven fields as CLOB V2 -- `salt, maker, signer, tokenId, makerAmount, +takerAmount, side, signatureType, timestamp, metadata, builder` -- under a +different EIP-712 domain: + + {name: "Polymarket CTF Exchange", version: "3", chainId: 137, + verifyingContract: 0xe3333700cA9d93003F00f0F71f8515005F6c00Aa} + +There is one Exchange v3 and no neg-risk variant. The differences from the +CLOB V2 order in `polymarket_signing` are the domain version and contract, +a `timestamp` in **seconds** (V2 uses milliseconds), no `expiration` on the +wire, `side` sent as `0`/`1`, and `builder` that must be zero on the +requester gateway (`BUILDER_ATTRIBUTION_NOT_ALLOWED`). + +How each wallet signs: + +* **Proxy (1), Safe (2) and EOA (0)** sign the Exchange v3 `Order` typed data + directly; the signature is the standard 65 bytes. +* **Deposit Wallet (3)** signs Solady's `TypedDataSign` envelope -- the order + as `contents`, in the wallet's own `DepositWallet` v1 domain with the + wallet as `verifyingContract` and a zero `salt` -- and the signature is + wrapped for ERC-7739: inner signature, the Exchange v3 domain separator, + the order's struct hash, the `Order(...)` type string, its two-byte length. + +The requester gateway refuses EOAs (`UNSUPPORTED_REQUESTER_SIGNATURE_TYPE`); +quoters may use all four. The digest is signed by `WalletSigner.sign_digest`, +which is the Rust core's secp256k1 signer when it is installed and +`eth_account` otherwise; the bytes are the same. Nothing here touches the +CLOB V2 order signing. +""" +from __future__ import annotations + +from decimal import Decimal +from typing import Any, Literal + +from . import polymarket_signing as v2 +from .errors import InvalidOrder + +CHAIN_ID = v2.CHAIN_ID + +EXCHANGE_V3 = "0xe3333700cA9d93003F00f0F71f8515005F6c00Aa" +# Owns the ERC-1155 combo positions (Polymarket Protocol V2). +POSITION_MANAGER = "0x006F54F7f9A22e0000CC2AB60031000000ae9fEF" +ROUTER = "0x12121212006e4CD160D18e3f00711DA5c3372600" +AUTO_REDEEMER = "0xa1200000d0002264C9a1698e001292D00E1b00af" +# Addresses from the venue's contracts page and the market-makers guide, Polygon mainnet. +PUSD = v2.PUSD + +DOMAIN_NAME = v2.EXCHANGE_DOMAIN_NAME +DOMAIN_VERSION = "3" +DEPOSIT_WALLET_DOMAIN_NAME = "DepositWallet" +DEPOSIT_WALLET_DOMAIN_VERSION = "1" + +ORDER_TYPE = v2.ORDER_TYPE +ORDER_FIELDS = v2.ORDER_FIELDS +DOMAIN_FIELDS = v2.DOMAIN_FIELDS +TYPED_DATA_SIGN_FIELDS = [ + {"name": "contents", "type": "Order"}, + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + {"name": "salt", "type": "bytes32"}, +] +TYPED_DATA_SIGN_TYPE = ( + "TypedDataSign(Order contents,string name,string version,uint256 chainId," + "address verifyingContract,bytes32 salt)" + ORDER_TYPE +) + +ZERO_BYTES32 = v2.ZERO_BYTES32 +EOA, POLY_PROXY, POLY_GNOSIS_SAFE, DEPOSIT_WALLET = v2.EOA, v2.POLY_PROXY, v2.POLY_GNOSIS_SAFE, v2.DEPOSIT_WALLET +SIGNATURE_TYPES = (EOA, POLY_PROXY, POLY_GNOSIS_SAFE, DEPOSIT_WALLET) +# Both requester gateways refuse an EOA. +REQUESTER_SIGNATURE_TYPES = (POLY_PROXY, POLY_GNOSIS_SAFE, DEPOSIT_WALLET) + +E6 = 1_000_000 +BUY, SELL = 0, 1 + +SideLike = Literal["BUY", "SELL", 0, 1] + + +def domain() -> dict[str, Any]: + return {"name": DOMAIN_NAME, "version": DOMAIN_VERSION, "chainId": CHAIN_ID, "verifyingContract": EXCHANGE_V3} + + +def side_code(side: Any) -> int: + if side in (BUY, "BUY", "buy"): + return BUY + if side in (SELL, "SELL", "sell"): + return SELL + raise InvalidOrder(f"polymarket: an Exchange v3 order side is BUY (0) or SELL (1), not {side!r}") + + +# --------------------------------------------------------------------------- +# Identity: which address signs and which wallet the order spends from +# --------------------------------------------------------------------------- + +def order_identity(signature_type: int, *, signer: str, wallet: str) -> tuple[str, str]: + """`(signer_address, maker_address)` for a wallet type, as both RFQ + gateways define them: a Deposit Wallet is both (the account or session + key signs for it through ERC-1271); a proxy or Safe is signed for by the + account signer; an EOA is both itself.""" + if signature_type == DEPOSIT_WALLET: + return wallet, wallet + if signature_type in (POLY_PROXY, POLY_GNOSIS_SAFE): + return signer, wallet + if signature_type == EOA: + return signer, signer + raise InvalidOrder(f"polymarket: unknown signature type {signature_type}; expected one of {SIGNATURE_TYPES}") + + +def check_requester(signature_type: int) -> None: + """The requester gateways' wallet rule, before anything is sent.""" + if signature_type not in REQUESTER_SIGNATURE_TYPES: + raise InvalidOrder( + "polymarket: combo RFQs refuse EOA wallets (UNSUPPORTED_REQUESTER_SIGNATURE_TYPE); request from a " + "Deposit Wallet (POLYMARKET_SIGNATURE_TYPE=3, the default since May 2026), a proxy (1) or a Safe (2), " + "with POLYMARKET_FUNDER set to that wallet" + ) + + +# --------------------------------------------------------------------------- +# The order +# --------------------------------------------------------------------------- + +def build_order( + *, maker: str, signer: str, token_id: str | int, maker_amount: int, taker_amount: int, side: SideLike, + signature_type: int, timestamp_s: int, salt: int, metadata: str = ZERO_BYTES32, builder: str = ZERO_BYTES32, +) -> dict[str, Any]: + """The order as both gateways carry it (`ExchangeV3Order`), before the + signature: uint256 values as decimal strings, `side` and + `signatureType` as integers, `timestamp` in Unix seconds.""" + if int(maker_amount) <= 0 or int(taker_amount) <= 0: + raise InvalidOrder(f"polymarket: Exchange v3 amounts must be positive, got {maker_amount}/{taker_amount}") + if signature_type not in SIGNATURE_TYPES: + raise InvalidOrder(f"polymarket: unknown signature type {signature_type}; expected one of {SIGNATURE_TYPES}") + return { + "salt": str(int(salt)), + "maker": maker, + "signer": signer, + "tokenId": str(int(token_id)), + "makerAmount": str(int(maker_amount)), + "takerAmount": str(int(taker_amount)), + "side": side_code(side), + "signatureType": int(signature_type), + "timestamp": str(int(timestamp_s)), + "metadata": metadata or ZERO_BYTES32, + "builder": builder or ZERO_BYTES32, + } + + +def _message(order: dict[str, Any]) -> dict[str, Any]: + return { + "salt": int(order["salt"]), + "maker": order["maker"], + "signer": order["signer"], + "tokenId": int(order["tokenId"]), + "makerAmount": int(order["makerAmount"]), + "takerAmount": int(order["takerAmount"]), + "side": side_code(order["side"]), + "signatureType": int(order["signatureType"]), + "timestamp": int(order["timestamp"]), + "metadata": v2._bytes32(order["metadata"]), + "builder": v2._bytes32(order["builder"]), + } + + +def order_typed_data(order: dict[str, Any]) -> dict[str, Any]: + """`exchangeV3OrderTypedData`: what a proxy, Safe or EOA signs.""" + return { + "primaryType": "Order", + "types": {"EIP712Domain": DOMAIN_FIELDS, "Order": ORDER_FIELDS}, + "domain": domain(), + "message": _message(order), + } + + +def deposit_wallet_typed_data(order: dict[str, Any]) -> dict[str, Any]: + """`depositWalletTypedData`: the order nested in the Deposit Wallet's + `TypedDataSign` envelope, which a Deposit Wallet's signer signs.""" + return { + "primaryType": "TypedDataSign", + "types": {"EIP712Domain": DOMAIN_FIELDS, "Order": ORDER_FIELDS, "TypedDataSign": TYPED_DATA_SIGN_FIELDS}, + "domain": domain(), + "message": { + "contents": _message(order), + "name": DEPOSIT_WALLET_DOMAIN_NAME, + "version": DEPOSIT_WALLET_DOMAIN_VERSION, + "chainId": CHAIN_ID, + "verifyingContract": order["signer"], + "salt": b"\x00" * 32, + }, + } + + +def typed_data_for(order: dict[str, Any]) -> dict[str, Any]: + """The payload this order's wallet type signs.""" + if int(order["signatureType"]) == DEPOSIT_WALLET: + return deposit_wallet_typed_data(order) + return order_typed_data(order) + + +def domain_separator() -> bytes: + abi_encode, _, _, keccak, _ = v2._eth() + return keccak(abi_encode( + ["bytes32", "bytes32", "bytes32", "uint256", "address"], + [ + keccak(text="EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak(text=DOMAIN_NAME), keccak(text=DOMAIN_VERSION), CHAIN_ID, EXCHANGE_V3, + ], + )) + + +def struct_hash(order: dict[str, Any]) -> bytes: + """`hashStruct(Order)`, the ERC-7739 `contentsHash`.""" + abi_encode, _, _, keccak, _ = v2._eth() + m = _message(order) + return keccak(abi_encode( + ["bytes32", "uint256", "address", "address", "uint256", "uint256", "uint256", + "uint8", "uint8", "uint256", "bytes32", "bytes32"], + [keccak(text=ORDER_TYPE), m["salt"], m["maker"], m["signer"], m["tokenId"], m["makerAmount"], + m["takerAmount"], m["side"], m["signatureType"], m["timestamp"], m["metadata"], m["builder"]], + )) + + +def order_hash(order: dict[str, Any]) -> str: + """The EIP-712 digest of the Exchange v3 order: the order's id on chain + and, UNVERIFIED until P10, the `taker_order_hash` the gateway returns.""" + _, _, _, keccak, _ = v2._eth() + return "0x" + keccak(b"\x19\x01" + domain_separator() + struct_hash(order)).hex().removeprefix("0x") + + +def signing_digest(order: dict[str, Any]) -> bytes: + """The 32 bytes the signer signs for this order's wallet type.""" + _, _, _, keccak, _ = v2._eth() + contents = struct_hash(order) + if int(order["signatureType"]) != DEPOSIT_WALLET: + return keccak(b"\x19\x01" + domain_separator() + contents) + abi_encode = v2._eth()[0] + envelope = keccak(abi_encode( + ["bytes32", "bytes32", "bytes32", "bytes32", "uint256", "address", "bytes32"], + [ + keccak(text=TYPED_DATA_SIGN_TYPE), contents, keccak(text=DEPOSIT_WALLET_DOMAIN_NAME), + keccak(text=DEPOSIT_WALLET_DOMAIN_VERSION), CHAIN_ID, order["signer"], b"\x00" * 32, + ], + )) + return keccak(b"\x19\x01" + domain_separator() + envelope) + + +def wrap_erc7739(inner: bytes, order: dict[str, Any]) -> str: + """A Deposit Wallet's submitted signature: `inner || appDomainSeparator + || contentsHash || contentsType || uint16(len(contentsType))`.""" + if len(inner) != 65: + raise InvalidOrder(f"polymarket: an inner signature is 65 bytes, got {len(inner)}") + return "0x" + ( + inner + domain_separator() + struct_hash(order) + ORDER_TYPE.encode() + len(ORDER_TYPE).to_bytes(2, "big") + ).hex() + + +def sign_order(signer: v2.WalletSigner, order: dict[str, Any]) -> str: + """The order's `signature` for its wallet type (see the module doc).""" + inner = signer.sign_digest(signing_digest(order)) + if int(order["signatureType"]) == DEPOSIT_WALLET: + return wrap_erc7739(inner, order) + return "0x" + bytes(inner).hex() + + +def signed_order(signer: v2.WalletSigner, order: dict[str, Any]) -> dict[str, Any]: + return {**order, "signature": sign_order(signer, order)} + + +# --------------------------------------------------------------------------- +# Amounts (six-decimal base units throughout) +# --------------------------------------------------------------------------- + +def quote_size(unit: str, value_e6: int, price_e6: int) -> int: + """A request's full size in share base units at a quote price: a + `notional` (BUY) request buys `floor(value * 1e6 / price)` shares; a + `shares` (SELL) request is its value.""" + if unit == "shares": + return int(value_e6) + if unit == "notional": + if price_e6 <= 0: + raise InvalidOrder("polymarket: a quote price must be positive") + return (int(value_e6) * E6) // int(price_e6) + raise InvalidOrder(f"polymarket: unknown requested_size unit {unit!r}") + + +def maker_quote_amounts(direction: str, price_e6: int, size_e6: int) -> tuple[str, int, int]: + """`(token, makerAmount, takerAmount)` of a quoter's collateral-funded + order (the market-makers guide): to fill a requester's SELL of YES the + quoter buys YES (`ceil(price * size)` pUSD for `size` YES); to fill a BUY + of YES it buys NO (`ceil((1 - price) * size)` pUSD for `size` NO). Both + orders are BUYs; `token` names which position id is bought.""" + price_e6, size_e6 = int(price_e6), int(size_e6) + if not 0 < price_e6 < E6: + raise InvalidOrder(f"polymarket: a quote price is inside (0, 1); got {price_e6 / E6}") + if size_e6 <= 0: + raise InvalidOrder("polymarket: a quote size must be positive") + if direction == "SELL": + return "yes", _ceil_div(price_e6 * size_e6, E6), size_e6 + if direction == "BUY": + return "no", _ceil_div((E6 - price_e6) * size_e6, E6), size_e6 + raise InvalidOrder(f"polymarket: an RFQ direction is BUY or SELL, not {direction!r}") + + +def _ceil_div(a: int, b: int) -> int: + return -(-a // b) + + +def implied_price(side: int, maker_amount: int, taker_amount: int) -> Decimal: + """The YES limit price a requester's order commits to: pUSD per share + for a BUY (`maker / taker`), proceeds per share for a SELL (`taker / maker`).""" + maker, taker = Decimal(int(maker_amount)), Decimal(int(taker_amount)) + return maker / taker if side == BUY else taker / maker + + +# --------------------------------------------------------------------------- +# Wallet calls +# --------------------------------------------------------------------------- + +def combo_approval_calls(*, include_router: bool = False, include_auto_redeem: bool = False) -> list[dict[str, str]]: + """The approvals a combo trade needs from the account wallet + (https://docs.polymarket.com/trading/combos/market-makers.md): + + * always: pUSD `approve(Exchange v3)` (an order that spends collateral) + and PositionManager `setApprovalForAll(Exchange v3)` (an order that + transfers combo positions); + * `include_router`: pUSD `approve(Router)` and PositionManager + `setApprovalForAll(Router)`, to split pUSD into positions and to + manage or redeem them through the Router; + * `include_auto_redeem`: PositionManager `setApprovalForAll(AutoRedeemer)`, + for automatic redemption. + """ + calls = [ + {"target": PUSD, "value": "0", "data": v2.approve_calldata(EXCHANGE_V3)}, + {"target": POSITION_MANAGER, "value": "0", "data": v2.set_approval_for_all_calldata(EXCHANGE_V3)}, + ] + if include_router: + calls += [ + {"target": PUSD, "value": "0", "data": v2.approve_calldata(ROUTER)}, + {"target": POSITION_MANAGER, "value": "0", "data": v2.set_approval_for_all_calldata(ROUTER)}, + ] + if include_auto_redeem: + calls.append({"target": POSITION_MANAGER, "value": "0", "data": v2.set_approval_for_all_calldata(AUTO_REDEEMER)}) + return calls diff --git a/synpath/trading/predict_fun.py b/synpath/trading/predict_fun.py index 4dbeb03..0b88285 100644 --- a/synpath/trading/predict_fun.py +++ b/synpath/trading/predict_fun.py @@ -10,9 +10,9 @@ Orders, cancels and account reads need a login token (a JWT), which the adapter gets by signing the venue's login message and renews when refused. -**YES and NO are separate tokens.** `buy` buys the YES token at the price -given; `sell` buys the NO token at `1 - price`; with `reduce_only`, `sell` -sells YES held and `buy` sells NO held. Everything the venue reports on the +**YES and NO are separate tokens.** An order buys or sells the token its +`outcome` and `action` name (with neither, `buy` buys YES and `sell` buys +NO at `1 - price`). Everything the venue reports on the NO token comes back on the YES leg. **Limits rest; market orders take.** A limit is `LIMIT` (good till @@ -71,18 +71,20 @@ RPC_URL = "https://bsc-dataseed.bnbchain.org" TESTNET_RPC_URL = "https://data-seed-prebsc-1-s1.bnbchain.org:8545" +# Rows per order, trade or position page. PAGE = 100 -"""Rows per order, trade or position page.""" +# Most pages one listing call walks. MAX_PAGES = 20 -"""Most pages one listing call walks.""" +# How long a market order's signature lives, as the SDK signs it. MARKET_EXPIRY_S = 300 -"""How long a market order's signature lives, as the SDK signs it.""" +# A login token this close to expiry is renewed before use. JWT_MARGIN_S = 60 -"""A login token this close to expiry is renewed before use.""" +# An order row's status. `INVALIDATED` is an order the chain no longer +# honours (cancelled there, or its funds gone). ROW_STATUS = { "OPEN": OrderStatus.OPEN, "FILLED": OrderStatus.CLOSED, @@ -90,20 +92,18 @@ "CANCELLED": OrderStatus.CANCELED, "INVALIDATED": OrderStatus.CANCELED, } -"""An order row's status. `INVALIDATED` is an order the chain no longer -honours (cancelled there, or its funds gone).""" +# The statuses the venue's order listing filters by. LIST_STATUS = {"open": "OPEN", "closed": "FILLED"} -"""The statuses the venue's order listing filters by.""" +# Order events whose status the event itself says. A transaction event's +# status follows from how much is filled. ORDER_EVENT_STATUS = { "orderAccepted": OrderStatus.OPEN, "orderNotAccepted": OrderStatus.REJECTED, "orderExpired": OrderStatus.EXPIRED, "orderCancelled": OrderStatus.CANCELED, } -"""Order events whose status the event itself says. A transaction event's -status follows from how much is filled.""" FILL_SETTLEMENT = { "orderTransactionSubmitted": SettlementState.MATCHED, @@ -478,6 +478,10 @@ class PredictFunTrading(TradingExchange): "fetch_queue_position": False, "fetch_fee_estimate": True, "rfq": False, + # Typed RFQ and combos arrive phase by phase (docs/rfq-combo.md). + "rfq_taker": False, + "rfq_quoter": False, + "combos": False, "split_merge": False, "watch_orders": False, "watch_my_trades": False, @@ -504,10 +508,10 @@ def __init__( self.signer = WalletSigner(credentials.private_key) self.account = Account(venue=VENUE, name=account_name) self.network = sig.TESTNET if credentials.testnet else sig.MAINNET + # The Predict account the key signs for, or `None` for a plain wallet. self.predict_account = credentials.account_address - """The Predict account the key signs for, or `None` for a plain wallet.""" + # Maker of every order, and holder of the funds. self.address = credentials.account_address or self.signer.address - """Maker of every order, and holder of the funds.""" # 240 requests a minute per API key, every kind together. self.limiter = limiter or BudgetLimiter(read_per_second=2, write_per_second=2) headers = {"x-api-key": credentials.api_key} if credentials.api_key else {} @@ -515,15 +519,15 @@ def __init__( base = url or (TESTNET_API_URL if credentials.testnet else API_URL) self.http = AsyncHttpClient(base, limiter=None, client=shared, venue=VENUE) self.rpc_url = rpc_url or credentials.rpc_url or (TESTNET_RPC_URL if credentials.testnet else RPC_URL) + # A BNB Chain node, for the balance read. self.rpc = rpc_client or httpx.AsyncClient(timeout=20, follow_redirects=True) - """A BNB Chain node, for the balance read.""" self._own_catalog = catalog is None if catalog is None: from ..predict_fun import PredictFun catalog = PredictFun(api_key=credentials.api_key, testnet=credentials.testnet) + # The read adapter: tokens, exchange and fee rate for a market. self.catalog = catalog - """The read adapter: tokens, exchange and fee rate for a market.""" self._markets: dict[str, MarketInfo] = {} self._jwt: str | None = None self._jwt_expires: int | None = None @@ -636,7 +640,8 @@ async def create_order(self, request: OrderRequest) -> Order: quantity=contracts, filled=Decimal("0"), remaining=contracts, - reduce_only=request.reduce_only, + outcome=request.leg[0], + action=request.leg[1], post_only=request.post_only, expires_at=request.expires_at, created_at=int(time.time() * 1000), @@ -654,7 +659,7 @@ async def create_order(self, request: OrderRequest) -> Order: return order return after.model_copy(update={ "client_order_id": order.client_order_id, "type": order.type, "time_in_force": order.time_in_force, - "book": order.book, "trader": order.trader, "tags": order.tags, "reduce_only": order.reduce_only, + "book": order.book, "trader": order.trader, "tags": order.tags, "outcome": order.outcome, "action": order.action, }) async def _remove(self, hashes: list[str]) -> dict[str, Any]: diff --git a/synpath/trading/predict_fun_signing.py b/synpath/trading/predict_fun_signing.py index 1aa377e..63a593b 100644 --- a/synpath/trading/predict_fun_signing.py +++ b/synpath/trading/predict_fun_signing.py @@ -39,16 +39,16 @@ ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" SCALE = 10**18 BUY, SELL = 0, 1 +# The only signature type the venue takes; a Predict account signs under it +# too, through EIP-1271. EOA = 0 -"""The only signature type the venue takes; a Predict account signs under it -too, through EIP-1271.""" MAX_SALT = 2_147_483_648 +# 2100-01-01, the SDK's expiration for an order that rests until cancelled. NO_EXPIRY = 4_102_444_800 -"""2100-01-01, the SDK's expiration for an order that rests until cancelled.""" +# The SDK's smallest order: 0.01 shares. MIN_SHARES_WEI = 10**16 -"""The SDK's smallest order: 0.01 shares.""" @dataclass(frozen=True) diff --git a/synpath/trading/sx_bet.py b/synpath/trading/sx_bet.py index be88225..ece9774 100644 --- a/synpath/trading/sx_bet.py +++ b/synpath/trading/sx_bet.py @@ -36,43 +36,59 @@ The *heartbeat* (`heartbeat(seconds)`) is a dead man's switch: unless renewed in time, the venue cancels every open order on the account. + +**Parlays** (the quoter side of docs/rfq-combo.md). A bettor's parlay request +on sx.bet makes a real market (the parent hash) that takes makers' orders for +60 seconds; the request is published once on `parlay_markets:global` and +nowhere else. Each one is an incoming `Rfq` (`mine=False`) on that market, +with its legs as a `Combo`. A quote is ordinary signed orders on the parlay +market: the bid on outcome one (the parlay wins), the ask on outcome two, +both resting until the parlay ends; the bettor takes one with FOK or IOC, so +there is no last look and no taker side over the API. """ from __future__ import annotations import asyncio +import inspect +import logging import time -from decimal import Decimal -from typing import Any +import uuid +from decimal import ROUND_DOWN, Decimal +from typing import Any, Callable from .. import ids from ..base import AsyncHttpClient, Capability -from ..errors import BadRequest, ExchangeError, MarketNotFound +from ..errors import BadRequest, ExchangeError, MarketNotFound, NotSupported from ..sx_bet import iso_ms from ..types import Page from . import sx_bet_signing as sig from .base import TradingExchange from .credentials import SxBetCredentials -from .errors import CredentialsMissing, InsufficientFunds, InvalidOrder, OrderNotFound, OrderRejected +from .errors import CredentialsMissing, InsufficientFunds, InvalidOrder, OrderNotFound, OrderRejected, RfqNotFound from .limiter import BudgetLimiter, Priority from .polymarket_signing import WalletSigner from .types import ( Account, Balance, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, SettlementState, Side, TimeInForce, VENUE_ORDER_TYPES, + Combo, ComboLeg, MakerQuote, QuoteStatus, Rfq, RfqCloseReason, RfqQuote, RfqStatus, ) +log = logging.getLogger("synpath.trading.sx_bet") + VENUE = "sx_bet" API_URL = "https://api.sx.bet" TESTNET_API_URL = "https://api.toronto.sx.bet" USDC = Decimal(1_000_000) ODDS = Decimal(10) ** 20 +# How long a GTC order is signed to live: the venue needs an expiry, and a +# game's orders are swept at kickoff long before this. DEFAULT_EXPIRY_S = 30 * 86400 -"""How long a GTC order is signed to live: the venue needs an expiry, and a -game's orders are swept at kickoff long before this.""" +# How long `create_order` lets the venue wait for the matching result. WAIT_MS = 10_000 -"""How long `create_order` lets the venue wait for the matching result.""" +# What the venue's synchronous answer says became of an order on arrival. OUTCOME_STATUS = { "RESTED": OrderStatus.OPEN, "PARTIAL_FILL_RESTED": OrderStatus.OPEN, @@ -81,14 +97,13 @@ "CANCELLED": OrderStatus.CANCELED, "TIMEOUT": OrderStatus.PENDING, } -"""What the venue's synchronous answer says became of an order on arrival.""" +# An inactive order's state by its `inactiveReason`; every other reason +# (cancelled by the account, the venue, a heartbeat, kickoff) is a cancel. INACTIVE_STATUS = { "FILLED": OrderStatus.CLOSED, "EXPIRED": OrderStatus.EXPIRED, } -"""An inactive order's state by its `inactiveReason`; every other reason -(cancelled by the account, the venue, a heartbeat, kickoff) is a cancel.""" def D(value: Any) -> Decimal: @@ -151,8 +166,10 @@ def order_of_row(row: dict[str, Any], *, account: Account | None = None) -> Orde def fill_of_row(row: dict[str, Any], *, account: Account | None = None) -> Fill | None: """One fill (`GET /fills-v3`, the fills channel): the contracts are its - return, one dollar each; the price its odds, on the YES leg.""" - if row.get("isParlay"): + return, one dollar each; the price its odds, on the YES leg. A parlay's + fill is on the parlay market itself (its `marketHash` is the parent), so + it books on the combo's own id like any other market's.""" + if not row.get("marketHash"): return None outcome_one = bool(row.get("isBettingOutcomeOne")) state = str(row.get("status") or "") @@ -222,6 +239,176 @@ def error_of(exc: ExchangeError) -> ExchangeError: info=exc.body if isinstance(exc.body, dict) else {}, body=exc.body) +# --------------------------------------------------------------------------- +# Parlays (docs/rfq-combo.md, P8) +# --------------------------------------------------------------------------- + +# A parlay market takes orders for 60 seconds after the bettor's request. +PARLAY_LIFETIME_S = 60 + +# How much earlier than the estimated end of the parlay a quote's orders are +# signed to expire. The request carries no expiry, only the 60 s rule, and it +# reaches us after it was made; an order signed past the parlay's end is +# refused (`PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY`, which names the exact +# end, and the quote is re-signed to it once). +PARLAY_EXPIRY_MARGIN_S = 3 + +# How long a parlay request is remembered after it closes, so a quote on it +# can still be read back by its RFQ. +PARLAY_KEEP_S = 600 + +VOID_LEG_RULE = ( + "SX Bet's documents disagree on a VOID leg: the developer docs (developers/parlays) say one VOID leg voids the " + "parlay and every stake is returned; the user guide (user-guides/trading/parlays) says a VOID or push leg loses " + "a parlay WIN bet, and a parlay LOSE bet needs no VOID leg to win. Synpath assumes the worse reading for the side " + "held (parlay_result's `worst`) and books nothing from a leg itself: the venue's settlement is the record." +) + +LEG_RESULTS = frozenset({"won", "lost", "void", "open"}) + + +def parlay_result(legs: list[str], *, outcome_one: bool = True) -> dict[str, str]: + """What a parlay bet is worth from its legs' results, each `won`, `lost`, + `void` (or push) or `open`, from the parlay's side (`won` = the leg went + the way the parlay needs). `outcome_one` is the parlay WIN side (the + bettor's request, a maker's bid); outcome two bets against it. + + Returns each document's reading (`developer_docs`, `user_guide`) and the + `worst` of the two for the side held, as one of `won`, `lost`, `refund` + or `open`. See `VOID_LEG_RULE`.""" + unknown = set(legs) - LEG_RESULTS + if unknown: + raise ValueError(f"leg results are won, lost, void or open, not {sorted(unknown)}") + + void, lost, still_open = "void" in legs, "lost" in legs, "open" in legs + if outcome_one: # parlay WIN + dev = "refund" if void else "lost" if lost else "open" if still_open else "won" + guide = "lost" if void or lost else "open" if still_open else "won" + else: # parlay LOSE + dev = "refund" if void else "won" if lost else "open" if still_open else "lost" + guide = "lost" if void else "won" if lost else "open" if still_open else "lost" + readings = (dev, guide) + if "lost" in readings: + worst = "lost" + elif "open" in readings: + worst = "open" + else: + worst = "refund" if "refund" in readings else "won" + return {"developer_docs": dev, "user_guide": guide, "worst": worst} + + +def combo_of_parlay(row: dict[str, Any], *, created_at: int | None = None) -> Combo: + """The parlay's legs as a `Combo` on the parlay market's own id. A leg's + `bettingOutcomeOne` is the bettor's side of it: `yes` is outcome one.""" + parent = str(row.get("marketHash") or "").lower() + legs = [ + ComboLeg( + market_id=ids.qualify(VENUE, str(leg.get("marketHash") or "").lower()), + outcome="yes" if leg.get("bettingOutcomeOne") else "no", + info={"bettingOutcomeOne": bool(leg.get("bettingOutcomeOne"))}, + ) + for leg in row.get("legs") or [] if isinstance(leg, dict) and leg.get("marketHash") + ] + return Combo( + market_id=ids.qualify(VENUE, parent), venue=VENUE, legs=legs, status="open", created_at=created_at, + info={"parlay": True, "void_leg_rule": VOID_LEG_RULE, "chainVersion": row.get("chainVersion")}, + ) + + +def rfq_of_parlay(row: dict[str, Any], *, received_at: int) -> Rfq: + """A parlay request (`parlay_markets:global`) as an incoming RFQ on the + parlay market. Two-sided: the request does not say which side of the + parlay the bettor will take. `amount` is `requestSize`, the stake asked + for (USDC, six decimals), which does not cap what a maker may offer. + `expires_at` is an estimate (`info["expiry_exact"]` False): the request + names no end, only the 60 s rule, counted here from its arrival.""" + parent = str(row.get("marketHash") or "").lower() + if not parent: + raise ValueError("a parlay request names its market") + size = row.get("requestSize") + return Rfq( + id=parent, venue=VENUE, combo_id=ids.qualify(VENUE, parent), + combo=combo_of_parlay(row, created_at=received_at), + amount=D(size) / USDC if size not in (None, "", "0", 0) else None, + mine=False, created_at=received_at, expires_at=received_at + PARLAY_LIFETIME_S * 1000, + info={**{k: v for k, v in row.items() if k != "legs"}, "expiry_exact": False}, + ) + + +QUOTE_SEPARATOR = "~" + + +def quote_id_of(bid_order: str | None, ask_order: str | None) -> str: + """A quote's id: its two orders' ids, bid first (`0xbid~0xask`, `0xbid~`, + `~0xask`), so a quote is read and cancelled from its id alone.""" + return f"{(bid_order or '').lower()}{QUOTE_SEPARATOR}{(ask_order or '').lower()}" + + +def order_ids_of(quote_id: str) -> tuple[str | None, str | None]: + if QUOTE_SEPARATOR not in quote_id: + return quote_id.lower() or None, None + bid, _, ask = quote_id.partition(QUOTE_SEPARATOR) + return bid.lower() or None, ask.lower() or None + + +def quote_of_orders(bid: Order | None, ask: Order | None, *, rfq_id: str | None = None, + account: Account | None = None) -> RfqQuote: + """Our quote from the orders that make it: the bid is a YES buy (outcome + one, the parlay wins), the ask a YES sell (outcome two at one minus it). + Open while either order rests; executed once neither rests and one + filled; else expired (both expired) or canceled.""" + orders = [o for o in (bid, ask) if o is not None] + if not orders: + raise ValueError("a quote has at least one order") + resting = any(o.status in (OrderStatus.OPEN, OrderStatus.PENDING) for o in orders) + filled = [o for o in orders if o.filled > 0] + if resting: + status = QuoteStatus.OPEN + elif filled: + status = QuoteStatus.EXECUTED + elif all(o.status == OrderStatus.EXPIRED for o in orders): + status = QuoteStatus.EXPIRED + else: + status = QuoteStatus.CANCELED + market_id = orders[0].market_id + expiries = [o.expires_at for o in orders if o.expires_at] + created = [o.created_at for o in orders if o.created_at] + return RfqQuote.from_venue({ + "id": quote_id_of(bid.id if bid else None, ask.id if ask else None), + "rfq_id": rfq_id or ids.native(VENUE, market_id), + "venue": VENUE, "account": account, "market_id": market_id, + "bid": bid.price if bid else None, "ask": ask.price if ask else None, + "bid_quantity": bid.quantity if bid else None, "ask_quantity": ask.quantity if ask else None, + "status": status, "accepted_side": None, + "maker_order_id": filled[0].id if filled else None, + "expires_at": min(expiries) if expiries else None, "mine": True, + "created_at": min(created) if created else None, + "info": { + "orders": {"bid": bid.id if bid else None, "ask": ask.id if ask else None}, + "filled": {"bid": str(bid.filled) if bid else None, "ask": str(ask.filled) if ask else None}, + "statuses": {"bid": bid.status.value if bid else None, "ask": ask.status.value if ask else None}, + }, + }) + + +def quote_error_of(exc: ExchangeError) -> ExchangeError: + """A refusal of a quote's orders, with the parlay codes read first.""" + body = exc.body if isinstance(exc.body, dict) else {} + text = reason_of(exc.body, str(exc)) + upper = text.upper() + if "PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY" in upper: + return InvalidOrder(f"{VENUE}: the quote's orders expire after the parlay does " + f"(parlay ends {body.get('parlayExpiry')}): {text}") + if "PARLAY_LEG_INACTIVE" in upper: + return OrderRejected(f"{VENUE}: leg {body.get('legMarketHash')} of parlay {body.get('parentMarketHash')} is " + f"{body.get('legStatus') or 'inactive'}; the parlay takes no orders", reason="parlay_leg_inactive", + info=body, body=exc.body) + if upper.startswith("INVALID_MARKET") or upper == "INVALID_MARKET": + return OrderRejected(f"{VENUE}: the parlay market is closed (its 60 s are over) or unknown: {text}", + reason="parlay_expired", info=body, body=exc.body) + return error_of(exc) + + # --------------------------------------------------------------------------- # Orders # --------------------------------------------------------------------------- @@ -243,8 +430,6 @@ def check_request(request: OrderRequest) -> None: raise InvalidOrder(f"{VENUE}: day orders are the engine's; gtc, gtd, ioc or fok") if request.price is None: raise InvalidOrder(f"{VENUE}: a price is required -- a market order is sent at the worst price you give") - if request.reduce_only: - raise InvalidOrder(f"{VENUE}: the venue nets every bet on a market; there is no reduce-only order") if request.post_only and venue_time_in_force(request) != "GTC": raise InvalidOrder(f"{VENUE}: a post-only order cannot also take at once") @@ -368,6 +553,17 @@ class SxBetTrading(TradingExchange): "fetch_queue_position": False, "fetch_fee_estimate": True, "rfq": False, + # Parlays (docs/rfq-combo.md, P8). No API asks for a parlay: a bettor + # requests one on sx.bet (docs.sx.bet/developers/parlays), so there is + # no taker side here. + "rfq_taker": False, + # A parlay request from `parlay_markets:global` is an incoming RFQ; a + # quote is one or two signed orders on the parlay market, expiring + # with it. No last look: `confirm_quote` is not supported. + "rfq_quoter": True, + # A parlay market is a combo with its own id, read from the requests + # seen; none is created through the API. + "combos": True, "split_merge": False, "watch_orders": False, "watch_my_trades": False, @@ -385,13 +581,14 @@ def __init__( limiter: BudgetLimiter | None = None, timeout: float = 30.0, client: Any = None, + parlay_stream: Any = None, ): import httpx self.credentials = credentials self.testnet = bool(credentials.testnet) + # Signs new orders; `None` with the API key alone, which does everything else. self.signer = WalletSigner(credentials.private_key) if credentials.private_key else None - """Signs new orders; `None` with the API key alone, which does everything else.""" self.account = Account(venue=VENUE, name=account_name) self.limiter = limiter or BudgetLimiter(read_per_second=4, write_per_second=10) self.url = url or (TESTNET_API_URL if self.testnet else API_URL) @@ -402,9 +599,20 @@ def __init__( from ..sx_bet import SxBet catalog = SxBet(api_key=credentials.api_key, api_url=self.url) + # The read adapter: fee schedules. self.catalog = catalog - """The read adapter: fee schedules.""" self._meta: dict[str, Any] | None = None + # The `parlay_markets:global` stream parlay requests come from; an + # `SxBetParlayStream` is made on the first `watch_parlays()` if none is given. + self.parlay_stream = parlay_stream + self._own_parlay_stream = parlay_stream is None + self._parlay_task: asyncio.Task | None = None + # Parlay requests seen on the stream, by parlay market hash. + self.parlays: dict[str, Rfq] = {} + self._rfq_listeners: list[Callable[[Rfq], Any]] = [] + # Quote id -> parlay hash, for the quotes sent through this adapter. + self._quotes: dict[str, str] = {} + self.clock: Callable[[], float] = time.time @property def address(self) -> str: @@ -465,14 +673,18 @@ async def heartbeat(self, timeout_s: int) -> dict[str, Any]: # -- orders --------------------------------------------------------------- + async def _post_orders(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]: + """`POST /orders-v3`, synchronous; the venue's refusal raised as it came.""" + raw = await self._call("POST", "/orders-v3", body={ + "orders": items, "waitForOutcome": True, "maxWaitTime": WAIT_MS, + }, kind="write") or {} + return list(raw.get("orders") or []) + async def _place(self, items: list[dict[str, Any]]) -> list[dict[str, Any]]: try: - raw = await self._call("POST", "/orders-v3", body={ - "orders": items, "waitForOutcome": True, "maxWaitTime": WAIT_MS, - }, kind="write") or {} + return await self._post_orders(items) except (BadRequest, OrderNotFound) as exc: raise error_of(exc) from None - return list(raw.get("orders") or []) async def create_order(self, request: OrderRequest) -> Order: """Sign and place one order. The venue answers with what matched on @@ -599,9 +811,9 @@ async def fetch_my_trades( self, *, market_id: str | None = None, order_id: str | None = None, since: int | None = None, limit: int | None = None, cursor: str | None = None, ) -> Page[Fill]: - """One page of this account's fills, newest first; parlays are left - out. The venue filters by order but not by market, so a market is - filtered here.""" + """One page of this account's fills, newest first; a parlay's are on + the parlay market's own id. The venue filters by order but not by + market, so a market is filtered here.""" raw = await self._call("GET", "/fills-v3", params={ "orderId": order_id.lower() if order_id else None, "startDate": _iso(since) if since else None, @@ -663,14 +875,321 @@ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, q currency="USDC", info={"schedule": schedule.model_dump(), "charged": "at settlement, if the bet wins"}, ) + # -- parlays: incoming requests (docs/rfq-combo.md, P8) ------------------- + # + # A bettor's parlay request makes a real market (the parent hash) that + # takes makers' orders for 60 s. The venue publishes each request once on + # `parlay_markets:global` and has no REST listing of open requests, so + # the requests this adapter knows are the ones its stream has seen. + + def _now_ms(self) -> int: + return int(self.clock() * 1000) + + def _parlay_state(self, rfq: Rfq) -> Rfq: + """The request as it stands now: closed (`expired`) once its 60 s are up.""" + if rfq.status == RfqStatus.OPEN and rfq.expires_at is not None and rfq.expires_at <= self._now_ms(): + return rfq.model_copy(update={"status": RfqStatus.CLOSED, "closed_reason": RfqCloseReason.EXPIRED}) + return rfq + + def _prune_parlays(self) -> None: + cutoff = self._now_ms() - PARLAY_KEEP_S * 1000 + for parent in [h for h, r in self.parlays.items() if (r.expires_at or 0) < cutoff]: + self.parlays.pop(parent, None) + + def on_parlay(self, request: dict[str, Any] | Rfq, *, received_at: int | None = None) -> Rfq | None: + """Take one parlay request (the channel's row, or the `Rfq` the + stream made of it). A request seen before is kept as first seen. + Every listener of `subscribe_incoming_rfqs` hears a new one at once.""" + try: + rfq = request if isinstance(request, Rfq) else rfq_of_parlay( + request, received_at=received_at if received_at is not None else self._now_ms()) + except ValueError as exc: + log.warning("synpath.trading.sx_bet: unreadable parlay request: %s", exc) + return None + self._prune_parlays() + if rfq.id in self.parlays: + return self.parlays[rfq.id] + self.parlays[rfq.id] = rfq + for listener in list(self._rfq_listeners): + try: + result = listener(rfq) + if inspect.isawaitable(result): + asyncio.ensure_future(result) + except Exception: + log.exception("synpath.trading.sx_bet: an incoming-RFQ listener failed") + return rfq + + def subscribe_incoming_rfqs(self, listener: Callable[[Rfq], Any]) -> Callable[[], None]: + """Call `listener(rfq)` (sync or async) for every new parlay request + as it arrives: the stream hook for a quoter that must answer inside + 60 s. The engine subscribes here at start (`engine.rfq.on_incoming`), + so its quoting callbacks hear a request at once and SX Bet is not + polled. Starts nothing: call `watch_parlays()` (or + `start_incoming_rfqs()`, or let the first `fetch_incoming_rfqs` do + it). Returns the unsubscribe.""" + self._rfq_listeners.append(listener) + return lambda: self._rfq_listeners.remove(listener) if listener in self._rfq_listeners else None + + async def start_incoming_rfqs(self) -> None: + """Start the parlay stream (the engine calls this when its incoming + feed starts): `watch_parlays()`.""" + await self.watch_parlays() + + async def watch_parlays(self) -> Any: + """Start listening to `parlay_markets:global`, once; returns the stream. + Needs the API key, as every SX Bet channel does.""" + if self._parlay_task is not None and not self._parlay_task.done(): + return self.parlay_stream + if self.parlay_stream is None: + from ..ws.sx_bet import SxBetParlayStream + + self.parlay_stream = SxBetParlayStream(api_key=self.credentials.api_key, testnet=self.testnet, + api_url=self.url) + await self.parlay_stream.watch_parlays() + self._parlay_task = asyncio.get_running_loop().create_task( + self._consume_parlays(self.parlay_stream), name="synpath-sx_bet-parlays") + return self.parlay_stream + + async def _consume_parlays(self, stream: Any) -> None: + async for event in stream: + if getattr(event, "name", None) != "parlay_request": + continue + payload = getattr(event, "payload", None) or {} + rfq = payload.get("rfq") + if rfq is not None: + self.on_parlay(rfq) + elif isinstance(payload.get("row"), dict): + self.on_parlay(payload["row"], received_at=getattr(event, "timestamp", None)) + + def _parlay(self, rfq_id: str) -> Rfq: + parent = ids.native(VENUE, rfq_id).lower() + rfq = self.parlays.get(parent) + if rfq is None: + raise RfqNotFound(f"{VENUE}: no parlay request {parent} seen on parlay_markets:global; the venue lists " + "no open requests over REST, so only those heard on the stream are known") + return self._parlay_state(rfq) + + async def fetch_incoming_rfqs( + self, *, market_id: str | None = None, limit: int | None = None, cursor: str | None = None, + ) -> Page[Rfq]: + """The parlay requests still open (inside their 60 s), newest first, + as the stream heard them. The first call starts the stream, so it + returns nothing until a request arrives. `market_id` matches the + parlay market or any of its legs.""" + await self.watch_parlays() + self._prune_parlays() + rfqs = [self._parlay_state(r) for r in self.parlays.values()] + rfqs = [r for r in rfqs if r.status == RfqStatus.OPEN] + if market_id: + wanted = ids.qualify(VENUE, ids.native(VENUE, market_id).lower()) + rfqs = [r for r in rfqs if r.combo_id == wanted or any(l.market_id == wanted for l in r.combo.legs)] + rfqs.sort(key=lambda r: r.created_at or 0, reverse=True) + return Page(rfqs[:limit] if limit else rfqs, next_cursor=None) + + async def fetch_rfq(self, rfq_id: str) -> Rfq: + """One parlay request the stream heard, open or (after its 60 s) closed.""" + return self._parlay(rfq_id) + + async def fetch_combo(self, combo_id: str) -> Combo: + """A parlay market's legs, from its request.""" + combo = self._parlay(combo_id).combo + assert combo is not None + return combo + + async def lookup_combo(self, legs: list[ComboLeg]) -> Combo | None: + """The newest parlay seen with exactly these legs and sides, or `None`.""" + wanted = {(l.market_id, l.outcome) for l in legs} + for rfq in sorted(self.parlays.values(), key=lambda r: r.created_at or 0, reverse=True): + if rfq.combo is not None and {(l.market_id, l.outcome) for l in rfq.combo.legs} == wanted: + return rfq.combo + return None + + async def create_combo(self, legs: list[ComboLeg], *, collection_id: str | None = None) -> Combo: + raise NotSupported(f"{VENUE}: a parlay market is made only by a bettor's request on sx.bet; the API has no " + "way to request one (docs.sx.bet/developers/parlays), so combos are read, not created") + + # -- parlays: quoting ----------------------------------------------------- + + def _parlay_expiry_s(self, rfq: Rfq) -> int: + """The latest a quote's orders may be signed to live, in seconds.""" + end = int((rfq.expires_at or self._now_ms() + PARLAY_LIFETIME_S * 1000) // 1000) + return end if rfq.info.get("expiry_exact") else end - PARLAY_EXPIRY_MARGIN_S + + def _quote_sides(self, quote: MakerQuote, rfq: Rfq, expiry_s: int, key: str) -> list[tuple[str, OrderRequest]]: + """The orders a quote is: a bid buys YES (outcome one, the parlay + wins), an ask sells YES (outcome two at one minus it), each `gtd` to + the parlay's end. Without a quantity each side covers the bettor's + requested stake at the price the bettor would pay.""" + sides: list[tuple[str, OrderRequest]] = [] + for label, price, side in (("bid", quote.bid, Side.BUY), ("ask", quote.ask, Side.SELL)): + if price is None: + continue + quantity = quote.quantity + if quantity is None: + if rfq.amount is None: + raise BadRequest(f"{VENUE}: parlay {rfq.id} gives no requested size; quote a quantity") + paid = price if side == Side.SELL else Decimal("1") - price + quantity = (rfq.amount / paid).quantize(Decimal("0.000001"), rounding=ROUND_DOWN) + sides.append((label, OrderRequest( + market_id=rfq.combo_id, side=side, type=OrderType.LIMIT, price=price, quantity=quantity, + time_in_force=TimeInForce.GTD, expires_at=expiry_s * 1000, post_only=quote.post_only, + client_order_id=f"{key}:{label}", account=quote.account, book=quote.book, trader=quote.trader, + tags=quote.tags, + ))) + return sides + + async def send_quote(self, quote: MakerQuote) -> RfqQuote: + """Answer a parlay request with signed orders on the parlay market: + the bid as an outcome-one order, the ask as an outcome-two order, + both `GTC` on the venue with the expiry the parlay allows (its 60 s + less `PARLAY_EXPIRY_MARGIN_S`, or `quote.expires_at` if sooner). The + bettor takes one with a FOK or IOC order; there is no last look. + + The quote's id names both orders (`quote_id_of`). A side the venue + refuses while the other is placed leaves a one-sided quote, with the + refusal in `info["refused"]`; both refused raises the first.""" + rfq = self._parlay(quote.rfq_id) + if rfq.status != RfqStatus.OPEN: + raise OrderRejected(f"{VENUE}: parlay {rfq.id} has closed (its 60 s are over)", reason="parlay_expired") + signer = self._order_signer() + meta = await self.metadata() + key = quote.client_order_id or f"sx-quote-{uuid.uuid4().hex[:16]}" + now = self.clock() + for attempt in range(2): + expiry_s = self._parlay_expiry_s(rfq) + if quote.expires_at is not None: + expiry_s = min(expiry_s, int(quote.expires_at // 1000)) + if expiry_s <= now: + raise OrderRejected(f"{VENUE}: parlay {rfq.id} ends before a quote could live", reason="parlay_expired") + sides = self._quote_sides(quote, rfq, expiry_s, key) + built = [(label, req, *build_signed_order(req, signer=signer, meta=meta, now=now)) for label, req in sides] + try: + results = await self._post_orders([b[2] for b in built]) + break + except (BadRequest, OrderNotFound) as exc: + body = exc.body if isinstance(exc.body, dict) else {} + exact = body.get("parlayExpiry") + if attempt == 0 and "PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY" in reason_of(exc.body, "") and exact: + # The venue names the parlay's real end: sign to it once. + rfq = rfq.model_copy(update={"expires_at": int(exact) * 1000, + "info": {**rfq.info, "expiry_exact": True}}) + self.parlays[rfq.id] = rfq + continue + raise quote_error_of(exc) from None + placed: dict[str, Order] = {} + refused: dict[str, Exception] = {} + answers = iter(results) + for label, req, _, order_id, odds, stake in built: + result = next(answers, {}) + if str(result.get("status") or "") != "SUBMITTED": + refused[label] = OrderRejected(f"{VENUE}: the {label} of the quote on parlay {rfq.id} was not " + f"submitted: {result}", reason="failed", info=result) + continue + order = order_after_placing(req, result, order_id=str(result.get("orderId") or order_id).lower(), + odds=odds, stake=stake, account=self.account) + placed[label] = order.model_copy(update={"expires_at": expiry_s * 1000}) + if not placed: + raise next(iter(refused.values())) + out = quote_of_orders(placed.get("bid"), placed.get("ask"), rfq_id=rfq.id, account=quote.account or self.account) + out = out.model_copy(update={"info": { + **out.info, "client_order_id": key, "expiry_s": expiry_s, "time_in_force": "GTC", + "refused": {k: str(v) for k, v in refused.items()}, + }}) + self._quotes[out.id] = rfq.id + return out + + async def cancel_quote(self, quote_id: str, *, rfq_id: str | None = None) -> None: + """Cancel the quote's orders (`DELETE /orders-v3`). An order already + filled or expired stays as it is; none found at all is `RfqNotFound`.""" + wanted = [o for o in order_ids_of(quote_id) if o] + if not wanted: + raise RfqNotFound(f"{VENUE}: {quote_id!r} names no order") + results = await self.cancel_orders(wanted) + missing = [r for r in results if isinstance(r, OrderNotFound)] + for result in results: + if isinstance(result, Exception) and not isinstance(result, OrderNotFound): + raise result + self._quotes.pop(quote_id, None) + if len(missing) == len(wanted): + raise RfqNotFound(f"{VENUE}: no order of quote {quote_id} at the venue") + + async def replace_quote(self, quote_id: str, quote: MakerQuote) -> RfqQuote: + """Cancel, then send: SX Bet has no order edit. A new id.""" + try: + await self.cancel_quote(quote_id, rfq_id=quote.rfq_id) + except RfqNotFound: + pass + return await self.send_quote(quote) + + async def confirm_quote(self, quote: RfqQuote) -> RfqQuote: + raise NotSupported(f"{VENUE}: no last look on parlays; a quote is a resting order the bettor takes with FOK " + "or IOC, so it trades without a maker's confirm (docs.sx.bet/api-reference/parlay-markets)") + + async def fetch_quote(self, quote_id: str, *, rfq_id: str | None = None) -> RfqQuote: + """One of our quotes, read back from its orders.""" + found: list[Order | None] = [] + for order_id in order_ids_of(quote_id): + if not order_id: + found.append(None) + continue + try: + found.append(await self.fetch_order(order_id)) + except OrderNotFound: + found.append(None) + bid, ask = found + if bid is None and ask is None: + raise RfqNotFound(f"{VENUE}: no order of quote {quote_id} at the venue") + quote = quote_of_orders(bid, ask, rfq_id=rfq_id or self._quotes.get(quote_id), account=self.account) + return quote if quote.id == quote_id else quote.model_copy(update={"id": quote_id}) + + async def fetch_my_quotes( + self, *, rfq_id: str | None = None, status: QuoteStatus | str | None = None, + ) -> list[RfqQuote]: + """Our quotes: those sent through this adapter, read back; with + `rfq_id`, also the resting orders on that parlay market, paired into + quotes by the client order id they were sent with (so a quote sent + in doubt is found again).""" + wanted = QuoteStatus(status) if status is not None else None + parent = ids.native(VENUE, rfq_id).lower() if rfq_id else None + quotes: dict[str, RfqQuote] = {} + for quote_id, held in list(self._quotes.items()): + if parent and held != parent: + continue + try: + quotes[quote_id] = await self.fetch_quote(quote_id, rfq_id=held) + except RfqNotFound: + self._quotes.pop(quote_id, None) + if parent: + pairs: dict[str, dict[str, Order]] = {} + for order in await self.fetch_open_orders(market_id=ids.qualify(VENUE, parent)): + coid = order.client_order_id or order.id + base, _, label = coid.rpartition(":") + if label not in ("bid", "ask"): + base, label = coid, "bid" if order.side == Side.BUY else "ask" + pairs.setdefault(base, {})[label] = order + for pair in pairs.values(): + quote = quote_of_orders(pair.get("bid"), pair.get("ask"), rfq_id=parent, account=self.account) + quotes.setdefault(quote.id, quote) + out = list(quotes.values()) + return [q for q in out if wanted is None or q.status == wanted] + async def close(self) -> None: + if self._parlay_task is not None: + self._parlay_task.cancel() + try: + await self._parlay_task + except (asyncio.CancelledError, Exception): + pass + self._parlay_task = None + if self._own_parlay_stream and self.parlay_stream is not None: + await self.parlay_stream.close() await self.http.close() if self._own_catalog: self.catalog.close() +# Most cancel-all calls one `cancel_all_orders` makes while the venue says more are left. MAX_CANCEL_ALL_ROUNDS = 50 -"""Most cancel-all calls one `cancel_all_orders` makes while the venue says more are left.""" def _iso(ms: int) -> str: @@ -679,4 +1198,7 @@ def _iso(ms: int) -> str: return iso(ms) or "" -__all__ = ["SxBetTrading", "order_of_row", "fill_of_row", "position_of", "build_signed_order"] +__all__ = [ + "SxBetTrading", "order_of_row", "fill_of_row", "position_of", "build_signed_order", + "rfq_of_parlay", "combo_of_parlay", "parlay_result", "quote_of_orders", "quote_id_of", "order_ids_of", +] diff --git a/synpath/trading/sx_bet_signing.py b/synpath/trading/sx_bet_signing.py index 3e65b14..ea511e3 100644 --- a/synpath/trading/sx_bet_signing.py +++ b/synpath/trading/sx_bet_signing.py @@ -27,10 +27,10 @@ ODDS_SCALE = 10 ** 20 USDC = 10 ** 6 +# `oddsLadderStepSize` is counted in these: 125 is 0.125%. LADDER_UNIT = 10 ** 15 -"""`oddsLadderStepSize` is counted in these: 125 is 0.125%.""" +# 0.1% and 99.9% are always on the ladder, whatever its step. LADDER_EXCEPTIONS = frozenset({10 ** 17, 999 * 10 ** 17}) -"""0.1% and 99.9% are always on the ladder, whatever its step.""" ORDER_TYPE = ( "Order(bytes32 marketHash,address baseToken,uint256 totalBetSize,uint256 percentageOdds,uint256 salt," diff --git a/synpath/trading/types.py b/synpath/trading/types.py index ae03e0e..ccd6da7 100644 --- a/synpath/trading/types.py +++ b/synpath/trading/types.py @@ -28,9 +28,9 @@ from decimal import Decimal from enum import Enum -from typing import Any +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator from .._deprecated import QUANTITY, rename_input, renamed_fields from ..types import iso @@ -42,16 +42,54 @@ class Side(str, Enum): `buy` takes the YES side, `sell` takes the NO side, and the price is always the YES price: selling at 0.70 is the same order as buying NO at 0.30. On a venue that nets a position (Kalshi, Polymarket US) that is - literally the order sent. On Polymarket, where YES and NO are separate - tokens, `sell` buys the NO token at `1 - price`; `sell` with - `reduce_only` sells the YES tokens held instead. `buy` with - `reduce_only` likewise sells NO tokens held rather than buying YES. + literally the order sent. Where YES and NO are separate tokens + (Polymarket and the like) the YES leg cannot say whether a `sell` sells + YES tokens held or buys NO tokens: an order says which with its + `outcome` and `action` (see `OrderRequest`). """ BUY = "buy" SELL = "sell" +# The outcome an order trades: which token it buys or sells. +Outcome = Literal["yes", "no"] +# What an order does with its outcome. +Action = Literal["buy", "sell"] + + +def yes_side(outcome: str, action: str) -> Side: + """An outcome and action on the YES leg: buying YES and selling NO are + YES buys; selling YES and buying NO are YES sells.""" + return Side.BUY if (outcome == "yes") == (action == "buy") else Side.SELL + + +def default_leg(side: Side | str) -> tuple[str, str]: + """The outcome and action a YES-leg side means when nothing else says: + `buy` buys YES, `sell` buys NO (opening, as an order with no position + in mind does).""" + return ("yes", "buy") if Side(side) == Side.BUY else ("no", "buy") + + +def legacy_reduce_only(data: Any, *, refuse: bool) -> Any: + """`reduce_only` is gone (16 October 2026): an order says what it does + with `outcome` and `action`. Written before that, a reduce-only order + sold the token its side names the other of: a reduce-only `sell` sold + YES held, a reduce-only `buy` sold NO held. Stored and venue-reported + data is read that way; a new request naming it is refused.""" + if not isinstance(data, dict) or "reduce_only" not in data: + return data + if refuse: + raise ValueError("reduce_only is removed: say what the order does with outcome (yes/no) and action " + "(buy/sell), e.g. outcome=yes, action=sell sells YES held") + data = dict(data) + reduce_only = bool(data.pop("reduce_only")) + side = data.get("side") + if reduce_only and side is not None and data.get("outcome") is None and data.get("action") is None: + data["outcome"], data["action"] = ("yes", "sell") if Side(side) == Side.SELL else ("no", "sell") + return data + + class OrderType(str, Enum): """What the venue holds (`limit`, `market`) and what the engine holds. @@ -72,7 +110,10 @@ class OrderType(str, Enum): TWAP = "twap" PEG = "peg" SMART_TAKER = "smart_taker" + # The requester's order from an accepted RFQ quote (docs/rfq-combo.md). RFQ_TAKER = "rfq_taker" + # The quoter's order from a quote it confirmed. + RFQ_MAKER = "rfq_maker" VENUE_ORDER_TYPES = frozenset({OrderType.LIMIT, OrderType.MARKET}) @@ -83,9 +124,9 @@ class TimeInForce(str, Enum): IOC = "ioc" FOK = "fok" GTD = "gtd" + # Not a venue concept on any of the three; the engine rewrites it to + # `gtd` at the configured session end before it reaches an adapter. DAY = "day" - """Not a venue concept on any of the three; the engine rewrites it to - `gtd` at the configured session end before it reaches an adapter.""" class TriggerSource(str, Enum): @@ -104,22 +145,22 @@ class TriggerSource(str, Enum): class OrderStatus(str, Enum): + # Accepted by this library, not yet acknowledged by the venue. PENDING = "pending" - """Accepted by this library, not yet acknowledged by the venue.""" + # Resting at the venue. Read `filled` and `remaining` for how much. OPEN = "open" - """Resting at the venue. Read `filled` and `remaining` for how much.""" PENDING_CANCEL = "pending_cancel" PENDING_REPLACE = "pending_replace" + # Fully filled. CLOSED = "closed" - """Fully filled.""" + # Ended by a cancel; `filled` may be non-zero. CANCELED = "canceled" - """Ended by a cancel; `filled` may be non-zero.""" REJECTED = "rejected" EXPIRED = "expired" + # An engine-held parent whose condition has not been met. WAITING = "waiting" - """An engine-held parent whose condition has not been met.""" + # An engine-held parent whose condition fired; its children are live. TRIGGERED = "triggered" - """An engine-held parent whose condition fired; its children are live.""" TERMINAL_STATUSES = frozenset({ @@ -133,13 +174,13 @@ class HeldBy(str, Enum): class SettlementState(str, Enum): + # Matched at the venue, not yet final. Polymarket only, before the chain + # confirms. MATCHED = "matched" - """Matched at the venue, not yet final. Polymarket only, before the chain - confirms.""" + # Final. CONFIRMED = "confirmed" - """Final.""" + # Matched and then failed on-chain; the shares never arrived. FAILED = "failed" - """Matched and then failed on-chain; the shares never arrived.""" class Liquidity(str, Enum): @@ -175,8 +216,8 @@ class Account(_Base): """ venue: str + # Which credential set, for a caller with more than one at a venue. name: str = "default" - """Which credential set, for a caller with more than one at a venue.""" subaccount: str | None = None @property @@ -188,67 +229,112 @@ def key(self) -> str: class Precision(_Base): """What an order for this instrument must satisfy before it is signed.""" + # Minimum price increment. Polymarket varies it per market. tick: Decimal - """Minimum price increment. Polymarket varies it per market.""" min_quantity: Decimal = Decimal("1") + # Contract increment where the venue has one; `None` means any decimal. quantity_step: Decimal | None = None - """Contract increment where the venue has one; `None` means any decimal.""" + # Polymarket US: integers only. whole_contracts: bool = False - """Polymarket US: integers only.""" face_value: Decimal = Decimal("1") + # Smallest order the venue takes, in collateral: what the token actually + # bought costs (Opinion: 5 USDT). `None` where the venue counts only + # contracts. min_notional: Decimal | None = None - """Smallest order the venue takes, in collateral: what the token actually - bought costs (Opinion: 5 USDT). `None` where the venue counts only - contracts.""" @renamed_fields(QUANTITY) # TODO(0.9): remove class OrderRequest(_Base): """What a caller asks for. Validated against the market before signing.""" + # Application ownership, assigned by the execution worker; not a venue account. + user_id: str | None = None + # Synpath id, `venue:native`. A bare native id is accepted by the venue's + # own adapter. market_id: str - """Synpath id, `venue:native`. A bare native id is accepted by the venue's - own adapter.""" + # `buy` takes YES, `sell` takes NO, on the YES leg. See `Side`. Set + # from `outcome` and `action` when they are given and it is not. side: Side - """`buy` takes YES, `sell` takes NO. See `Side`.""" + outcome: Outcome | None = None + # What the order does, in the caller's terms: buy or sell YES or NO. + # The engine, ledger and risk work on the YES leg (`side`, the YES + # `price`); a venue adapter reads these to send it. On a venue that nets + # (Kalshi, Polymarket US) Buy NO and Sell YES are the same order. Where YES + # and NO are separate tokens (Polymarket, Opinion, Limitless, predict.fun, + # Hyperliquid) they are not: `sell` sells the tokens held, `buy` buys them. + # Both or neither: with neither, `side` decides (`default_leg`: `buy` buys + # YES, `sell` buys NO). + action: Action | None = None + # Contracts. quantity: Decimal - """Contracts.""" type: OrderType = OrderType.LIMIT + # Always the YES price, 0 to 1. Required for a limit order. For a market + # order, the protection price the engine will not cross; refused without + # one on a venue without native market orders. price: Decimal | None = None - """Always the YES price, 0 to 1. Required for a limit order. For a market - order, the protection price the engine will not cross; refused without - one on a venue without native market orders.""" + # The level a stop triggers at: a buy stop when the watched price rises + # to it, a sell stop when it falls to it. stop_price: Decimal | None = None - """The level a stop triggers at: a buy stop when the watched price rises - to it, a sell stop when it falls to it.""" + # What a stop watches; see `TriggerSource`. Stops only. trigger_source: TriggerSource = TriggerSource.TOUCH - """What a stop watches; see `TriggerSource`. Stops only.""" + # For a stop, how long the whole order lives, armed and after it + # triggers, as IBKR and FIX use it: `gtc`, `gtd` or `day`. A `stop_limit`'s + # child inherits it (a `day` one as `gtd` at the same session end); a + # `stop_market`'s child is `ioc` within that life. `ioc` and `fok` are + # refused on a stop. time_in_force: TimeInForce = TimeInForce.GTC - """For a `stop_market`, how long the stop stays armed (`gtc`, `gtd`, - `day`); its triggered child is always `ioc`. For a `stop_limit`, what - the limit child carries once triggered (`gtc`, `gtd`, `ioc`, `fok`).""" + # Milliseconds since epoch. Required with `gtd`. expires_at: int | None = None - """Milliseconds since epoch. Required with `gtd`.""" post_only: bool = False - reduce_only: bool = False - """Only reduce an existing position. On Polymarket this is what decides - whether `sell` buys NO tokens or sells YES tokens held; see `Side`.""" + # The idempotency key. Generated before the first write if absent, and + # reused on every retry of the same intent. client_order_id: str | None = None - """The idempotency key. Generated before the first write if absent, and - reused on every retry of the same intent.""" account: Account | None = None + # Strategy or desk the order belongs to. Internal, independent of the + # venue account; positions and P&L roll up by it. book: str | None = None - """Strategy or desk the order belongs to. Internal, independent of the - venue account; positions and P&L roll up by it.""" trader: str | None = None tags: dict[str, str] = Field(default_factory=dict) notes: str | None = None + # Venue-specific extras passed through untouched (Kalshi + # `self_trade_prevention_type`, Polymarket `signatureType`, ...). params: dict[str, Any] = Field(default_factory=dict) - """Venue-specific extras passed through untouched (Kalshi - `self_trade_prevention_type`, Polymarket `signatureType`, ...).""" + + @classmethod + def stored(cls, data: Any) -> "OrderRequest": + """An instruction read back from a journal. It was valid when it was + accepted: rules added since (a stop's `ioc`/`fok` time in force) do + not stop an older order from being restored, listed or retried, and + an old `reduce_only` is read as the outcome and action it meant.""" + return cls.model_validate(data, context={"stored": True}) + + @property + def leg(self) -> tuple[str, str]: + """`(outcome, action)`: what the order does, given or from `side`.""" + if self.outcome is not None and self.action is not None: + return self.outcome, self.action + return default_leg(self.side) + + @model_validator(mode="before") + @classmethod + def _outcome_and_action(cls, data: Any, info: ValidationInfo) -> Any: + data = legacy_reduce_only(data, refuse=not (info.context or {}).get("stored")) + if not isinstance(data, dict): + return data + outcome, action = data.get("outcome"), data.get("action") + if (outcome is None) != (action is None): + raise ValueError("give outcome and action together (buy or sell, yes or no), or neither") + if outcome is not None: + implied = yes_side(str(outcome), str(action)) + if data.get("side") is None: + data = {**data, "side": implied} + elif Side(data["side"]) != implied: + raise ValueError(f"side {Side(data['side']).value} is not {action} {outcome} on the YES leg " + f"({implied.value}); give one or make them agree") + return data @model_validator(mode="after") - def _consistent(self) -> "OrderRequest": + def _consistent(self, info: ValidationInfo) -> "OrderRequest": if self.type == OrderType.LIMIT and self.price is None: raise ValueError("a limit order needs a price") if self.time_in_force == TimeInForce.GTD and self.expires_at is None: @@ -260,12 +346,12 @@ def _consistent(self) -> "OrderRequest": raise ValueError("stop_limit needs the limit price to submit once triggered") if self.trigger_source != TriggerSource.TOUCH and self.type not in STOP_TYPES: raise ValueError(f"trigger_source is for stop orders, not {self.type.value}") - if self.type == OrderType.STOP_MARKET and self.time_in_force in (TimeInForce.IOC, TimeInForce.FOK): - raise ValueError("a stop_market's time_in_force is how long it stays armed: gtc, gtd or day. " - "The order it sends when triggered is always ioc") + stored = bool((info.context or {}).get("stored")) + if self.type in (OrderType.STOP_MARKET, OrderType.STOP_LIMIT) and not stored \ + and self.time_in_force in (TimeInForce.IOC, TimeInForce.FOK): + raise ValueError(f"a {self.type.value}'s time_in_force is how long the whole order lives, armed and " + f"after it triggers: gtc, gtd or day, not {self.time_in_force.value}") if self.type == OrderType.STOP_LIMIT: - if self.time_in_force == TimeInForce.DAY: - raise ValueError("a stop_limit's time_in_force is gtc, gtd, ioc or fok; for the day, use gtd with expires_at") if self.side == Side.SELL and self.price > self.stop_price: # type: ignore[operator] raise ValueError(f"a sell stop_limit's price {self.price} must be at or below its stop_price {self.stop_price}") if self.side == Side.BUY and self.price < self.stop_price: # type: ignore[operator] @@ -282,23 +368,38 @@ class EditRequest(_Base): quantity: Decimal | None = None time_in_force: TimeInForce | None = None expires_at: int | None = None + # Idempotency key for the *edit*, so a retried edit is not applied twice. client_order_id: str | None = None - """Idempotency key for the *edit*, so a retried edit is not applied twice.""" @renamed_fields({**QUANTITY, "last_fill_amount": "last_fill_quantity"}) # TODO(0.9): remove class Order(_Base): """An order as the venue (or the engine) reports it.""" + user_id: str | None = None id: str client_order_id: str | None = None - venue: str + venue: str | None account: Account | None = None + # Eligible execution accounts captured by a routed engine parent. + # Its venue and singular account are null; actual children remain venue orders. + execution_accounts: dict[str, Account] = Field(default_factory=dict) + + @model_validator(mode="after") + def validate_execution_identity(self): + if self.execution_accounts: + if self.held_by != HeldBy.ENGINE or self.venue is not None or self.account is not None: + raise ValueError("cross-venue execution scope belongs to an engine parent without a singular venue/account") + if any(venue != account.venue for venue, account in self.execution_accounts.items()): + raise ValueError("execution account venue does not match its scope") + elif self.venue is None: + raise ValueError("an order without a venue needs its captured execution accounts") + return self + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" + # On the YES leg: `buy` is a YES order, `sell` a NO order, whatever the + # venue's own wording. `price` is the YES price. side: Side - """On the YES leg: `buy` is a YES order, `sell` a NO order, whatever the - venue's own wording. `price` is the YES price.""" type: OrderType time_in_force: TimeInForce status: OrderStatus @@ -307,31 +408,42 @@ class Order(_Base): stop_price: Decimal | None = None quantity: Decimal filled: Decimal = Decimal("0") + # `quantity - filled` unless the venue reports otherwise. remaining: Decimal | None = None - """`quantity - filled` unless the venue reports otherwise.""" average_price: Decimal | None = None + # Collateral committed so far. cost: Decimal | None = None - """Collateral committed so far.""" fee: Decimal | None = None fee_currency: str | None = None last_fill_price: Decimal | None = None last_fill_quantity: Decimal | None = None post_only: bool = False - reduce_only: bool = False + outcome: Outcome | None = None + # What the order does (see `OrderRequest.outcome`): as the caller gave it + # on an order the engine placed, as the venue reports it where YES and NO + # are separate tokens, else unknown (`None`). An order placed with a side + # alone has none: on a netting venue a sell may close YES or open NO. + action: Action | None = None expires_at: int | None = None created_at: int | None = None updated_at: int | None = None + # The engine-held parent this order is a child of. parent_id: str | None = None - """The engine-held parent this order is a child of.""" + # After an edit: whether the venue kept the order's place in the queue. + # Kalshi keeps it on a decrease and loses it on a price change; Polymarket + # always loses it because an edit is a cancel and a new order. queue_priority_preserved: bool | None = None - """After an edit: whether the venue kept the order's place in the queue. - Kalshi keeps it on a decrease and loses it on a price change; Polymarket - always loses it because an edit is a cancel and a new order.""" book: str | None = None trader: str | None = None tags: dict[str, str] = Field(default_factory=dict) info: dict[str, Any] = Field(default_factory=dict) + @model_validator(mode="before") + @classmethod + def _legacy(cls, data: Any) -> Any: + """An order stored before `reduce_only` was removed reads as the outcome and action it meant.""" + return legacy_reduce_only(data, refuse=False) + @model_validator(mode="after") def _derive(self) -> "Order": if self.remaining is None: @@ -355,16 +467,20 @@ class Fill(_Base): venue: str account: Account | None = None market_id: str + # On the YES leg, like `Order.side`. side: Side - """On the YES leg, like `Order.side`.""" + outcome: Outcome | None = None + # What the fill did, its order's outcome and action (see + # `OrderRequest.outcome`), where the order is known; else unknown. + action: Action | None = None + # The YES price. price: Decimal - """The YES price.""" quantity: Decimal fee: Decimal | None = None fee_currency: str | None = None liquidity: Liquidity = Liquidity.UNKNOWN + # `matched` until the chain confirms, on Polymarket. Final elsewhere. settlement: SettlementState = SettlementState.CONFIRMED - """`matched` until the chain confirms, on Polymarket. Final elsewhere.""" timestamp: int info: dict[str, Any] = Field(default_factory=dict) @@ -380,29 +496,29 @@ class Position(_Base): venue: str account: Account | None = None market_id: str + # `long` holds YES, `short` holds NO. side: PositionSide = PositionSide.FLAT - """`long` holds YES, `short` holds NO.""" + # Net exposure on the side named, positive. On a netting venue this is + # the position; on Polymarket it is `|inventory_yes - inventory_no|`. contracts: Decimal = Decimal("0") - """Net exposure on the side named, positive. On a netting venue this is - the position; on Polymarket it is `|inventory_yes - inventory_no|`.""" inventory_yes: Decimal | None = None + # Tokens actually held on each side, on a venue that does not net + # (Polymarket). `None` elsewhere. inventory_no: Decimal | None = None - """Tokens actually held on each side, on a venue that does not net - (Polymarket). `None` elsewhere.""" entry_price: Decimal | None = None mark_price: Decimal | None = None unrealized_pnl: Decimal | None = None realized_pnl: Decimal | None = None + # Collateral locked against this position, where the venue reports it. margin: Decimal | None = None - """Collateral locked against this position, where the venue reports it.""" resolved: bool = False + # Resolved and past any dispute window. On Polymarket a resolved market + # can still be challenged; `resolved` without `final` says so. final: bool = False - """Resolved and past any dispute window. On Polymarket a resolved market - can still be challenged; `resolved` without `final` says so.""" won: bool | None = None payout: Decimal | None = None + # Payout waiting to be claimed on-chain. Polymarket only. redeemable: Decimal | None = None - """Payout waiting to be claimed on-chain. Polymarket only.""" timestamp: int | None = None info: dict[str, Any] = Field(default_factory=dict) @@ -412,8 +528,8 @@ class Settlement(_Base): venue: str account: Account | None = None market_id: str + # Which side was held into settlement, when the venue says. held: PositionSide | None = None - """Which side was held into settlement, when the venue says.""" result: str | None = None won: bool | None = None quantity: Decimal | None = None @@ -432,12 +548,12 @@ class Balance(_Base): currency: str total: Decimal available: Decimal + # Committed to resting orders and margin, where the venue reports it. + # `None` means it does not, not zero. locked: Decimal | None = None - """Committed to resting orders and margin, where the venue reports it. - `None` means it does not, not zero.""" + # The venue's own figure for what can still be bought, where it has one + # (Polymarket US). `None` means the venue defines none, not zero. buying_power: Decimal | None = None - """The venue's own figure for what can still be bought, where it has one - (Polymarket US). `None` means the venue defines none, not zero.""" timestamp: int | None = None info: dict[str, Any] = Field(default_factory=dict) @@ -452,12 +568,12 @@ class FeeEstimate(_Base): price: Decimal quantity: Decimal taker_fee: Decimal | None = None + # Negative where the venue pays makers. maker_fee: Decimal | None = None - """Negative where the venue pays makers.""" builder_fee: Decimal | None = None + # The least one taker order pays, where the venue has a floor (Opinion: + # 0.25 USDT), already applied to `taker_fee`. `None` means no floor. min_fee: Decimal | None = None - """The least one taker order pays, where the venue has a floor (Opinion: - 0.25 USDT), already applied to `taker_fee`. `None` means no floor.""" currency: str = "USD" info: dict[str, Any] = Field(default_factory=dict) @@ -467,3 +583,343 @@ def _positive(cls, value: Decimal) -> Decimal: if value <= 0: raise ValueError("must be positive") return value + + +# -- RFQ and combos ------------------------------------------------------------- +# +# See docs/rfq-combo.md. A requester (the taker) asks for a quote on a market +# or a combo; makers (quoters) answer with a two-sided YES quote; the taker +# accepts one side; the maker confirms (a last look); the venue submits the +# two orders. Every price is a YES price, as everywhere else. + + +class RfqStatus(str, Enum): + OPEN = "open" + # No longer quotable; `Rfq.closed_reason` says why. + CLOSED = "closed" + + +class RfqCloseReason(str, Enum): + # Deleted by its creator. + CANCELED = "canceled" + EXPIRED = "expired" + # A quote on it traded. + EXECUTED = "executed" + # Closed by the venue because a newer RFQ took its place. + REPLACED = "replaced" + + +class QuoteStatus(str, Enum): + """`open` → `accepted` → `confirmed` → `executed`, or off the path to + `canceled`, `expired` or `failed` before `executed`.""" + + # Quotable: the requester may accept a side. + OPEN = "open" + # The requester took a side; the maker has not confirmed. + ACCEPTED = "accepted" + # The maker confirmed (its last look); the venue will submit the orders. + CONFIRMED = "confirmed" + # The orders were submitted. Not a fill: read the order's fills. + EXECUTED = "executed" + # The maker withdrew it, or its RFQ closed. + CANCELED = "canceled" + # Not accepted, or not confirmed, by its deadline. + EXPIRED = "expired" + # The venue reported that the execution failed; nothing traded. + FAILED = "failed" + + +QUOTE_TERMINAL_STATUSES = frozenset({ + QuoteStatus.EXECUTED, QuoteStatus.CANCELED, QuoteStatus.EXPIRED, QuoteStatus.FAILED, +}) + + +def _yes_price(value: Decimal | None, name: str) -> Decimal | None: + if value is not None and not (Decimal("0") < value < Decimal("1")): + raise ValueError(f"{name} {value} is a YES price and must be inside (0, 1)") + return value + + +def _positive(value: Decimal | None, name: str) -> Decimal | None: + if value is not None and value <= 0: + raise ValueError(f"{name} must be positive, got {value}") + return value + + +def _one_size(quantity: Decimal | None, amount: Decimal | None, *, required: bool) -> None: + if quantity is not None and amount is not None: + raise ValueError("size by quantity (contracts) or amount (money), not both") + if required and quantity is None and amount is None: + raise ValueError("give a size: quantity (contracts) or amount (money)") + _positive(quantity, "quantity") + _positive(amount, "amount") + + +def _leg_pair(outcome: str | None, action: str | None) -> None: + if action is not None and outcome is None: + raise ValueError("an action needs the outcome it acts on (yes or no)") + + +class ComboLeg(_Base): + """One market a combo needs, and which outcome of it.""" + + # Synpath id, `venue:native`. + market_id: str + # The outcome this leg needs. On Polymarket US `no` is `SIDE_SELL`. + outcome: Outcome + # The leg's event, where the venue wants it (Kalshi `event_ticker`). + event_id: str | None = None + info: dict[str, Any] = Field(default_factory=dict) + + +class Combo(_Base): + """A multi-leg instrument with its own market id: it pays out only if every + leg does. Positions, P&L and settlement live on `market_id`, like any + market's.""" + + # The combo's own Synpath id (`kalshi:KXMVE…`, `polymarket_us:caoc-…`). + market_id: str + venue: str + # Kalshi's multivariate event collection the combo was built in. + collection_id: str | None = None + legs: list[ComboLeg] + status: Literal["open", "closed", "settled"] | None = None + created_at: int | None = None + info: dict[str, Any] = Field(default_factory=dict) + + @field_validator("legs") + @classmethod + def _two_or_more(cls, legs: list[ComboLeg]) -> list[ComboLeg]: + if len(legs) < 2: + raise ValueError("a combo needs at least two legs") + if len({leg.market_id for leg in legs}) != len(legs): + raise ValueError("a combo names each market once") + return legs + + +class ComboMarket(_Base): + """One market the venue lets a combo take as a leg, from its own list of + combo-eligible markets (Polymarket's catalogue, Polymarket US's + `comboEnabled`). Kalshi lists eligibility by collection instead.""" + + # Synpath id, `venue:native`: the leg's `ComboLeg.market_id`. + market_id: str + venue: str + title: str | None = None + # The market's event, where the venue's list names it (Polymarket US). + event_id: str | None = None + event_title: str | None = None + # Per outcome (`yes`, `no`), where a leg is named by a position id + # (Polymarket: `leg.info["position_id"]`). + position_ids: dict[str, str] | None = None + min_legs: int = 2 + # The most legs a combo may have on this venue, where documented. + max_legs: int | None = None + # The venue's own market status, as listed. + status: str | None = None + tags: list[str] = Field(default_factory=list) + # The venue's own row. + info: dict[str, Any] = Field(default_factory=dict) + + +class Rfq(_Base): + """A request for quote: one market or one combo, sized by contracts or by + money, never both.""" + + id: str + venue: str + account: Account | None = None + # The single market asked about. Exactly one of this and `combo_id`. + market_id: str | None = None + # The combo's own market id (`Combo.market_id`) when the RFQ is on a combo. + combo_id: str | None = None + # The combo's legs, where known. + combo: Combo | None = None + outcome: Outcome | None = None + # The side asked for, where the venue asks one side (Polymarket global: + # YES, buy or sell). `None` on a two-sided request (Kalshi, Polymarket US). + action: Action | None = None + # Contracts. Exactly one of this and `amount`. + quantity: Decimal | None = None + # Money, in the venue's collateral (Kalshi `target_cost_dollars`, + # Polymarket US `cashOrderQty`). Not the old name for `quantity`. + amount: Decimal | None = None + # Leave what a quote does not fill resting on the book. + rest_remainder: bool = False + status: RfqStatus = RfqStatus.OPEN + closed_reason: RfqCloseReason | None = None + # Created by this account; `False` for an incoming RFQ a quoter sees. + mine: bool = True + created_at: int | None = None + expires_at: int | None = None + updated_at: int | None = None + info: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def _consistent(self) -> "Rfq": + if (self.market_id is None) == (self.combo_id is None): + raise ValueError("an RFQ is on a market_id or a combo_id, exactly one") + if self.combo is not None and self.combo_id is not None and self.combo.market_id != self.combo_id: + raise ValueError(f"combo {self.combo.market_id} is not combo_id {self.combo_id}") + _leg_pair(self.outcome, self.action) + # The venue may not echo a size back on an incoming RFQ; one we + # created always has one. + _one_size(self.quantity, self.amount, required=self.mine) + if self.closed_reason is not None and self.status != RfqStatus.CLOSED: + raise ValueError("closed_reason is for a closed RFQ") + return self + + @property + def instrument_id(self) -> str: + """What is quoted: the market or the combo's own market id.""" + return self.market_id or self.combo_id # type: ignore[return-value] + + +class RfqQuote(_Base): + """A maker's answer to an RFQ, both sides on the YES leg. + + `ask` is the YES price the requester can buy at (Kalshi `1 - no_bid`, + Polymarket US `buyPrice`); `bid` the YES price it can sell at (Kalshi + `yes_bid`, Polymarket US `sellPrice`). A side the maker declined is + `None`. Named `RfqQuote` because `synpath.Quote` is the market-data quote. + """ + + id: str + rfq_id: str + venue: str + account: Account | None = None + # The instrument quoted (a market or a combo's own id), where the venue says. + market_id: str | None = None + bid: Decimal | None = None + ask: Decimal | None = None + bid_quantity: Decimal | None = None + ask_quantity: Decimal | None = None + status: QuoteStatus = QuoteStatus.OPEN + # The YES-leg side the requester took, as the venue reports it. + accepted_side: Side | None = None + # When the maker must confirm (its last look) after an accept. + confirm_by: int | None = None + # When the venue submits the orders after a confirm. + execute_by: int | None = None + # When the quote stops being acceptable. + expires_at: int | None = None + # The requester's order once executed (Kalshi `rfq_creator_order_id`). + taker_order_id: str | None = None + # The quoter's order once executed (Kalshi `quote_creator_order_id`). + maker_order_id: str | None = None + # Made by this account (the quoter side); `False` for one we received. + mine: bool = False + created_at: int | None = None + updated_at: int | None = None + info: dict[str, Any] = Field(default_factory=dict) + + @classmethod + def from_venue(cls, data: Any) -> "RfqQuote": + """A quote as a venue reported it. A crossed quote (`bid > ask`) is + refused when a caller builds one, but a venue may send one: it is + kept, and `crossed` says so, rather than breaking a whole list.""" + return cls.model_validate(data, context={"venue": True}) + + @model_validator(mode="after") + def _consistent(self, info: ValidationInfo) -> "RfqQuote": + _yes_price(self.bid, "bid") + _yes_price(self.ask, "ask") + if self.bid is None and self.ask is None: + raise ValueError("a quote has a bid, an ask or both") + if self.crossed and not (info.context or {}).get("venue"): + raise ValueError(f"bid {self.bid} is above ask {self.ask}") + _positive(self.bid_quantity, "bid_quantity") + _positive(self.ask_quantity, "ask_quantity") + return self + + @property + def is_terminal(self) -> bool: + return self.status in QUOTE_TERMINAL_STATUSES + + @property + def crossed(self) -> bool: + """The bid is above the ask: only ever a venue's report (`from_venue`).""" + return self.bid is not None and self.ask is not None and self.bid > self.ask + + def price_for(self, outcome: str, action: str) -> Decimal | None: + """The YES price this quote trades at for `action` on `outcome`: + buying YES or selling NO takes the ask, selling YES or buying NO the + bid. `None` where the maker declined that side.""" + return self.ask if yes_side(outcome, action) == Side.BUY else self.bid + + def quantity_for(self, outcome: str, action: str) -> Decimal | None: + return self.ask_quantity if yes_side(outcome, action) == Side.BUY else self.bid_quantity + + +class AcceptQuote(_Base): + """A requester taking one side of a quote. Becomes an `rfq_taker` order.""" + + quote_id: str + # Needed by venues that address a quote under its RFQ (Kalshi, Polymarket US). + rfq_id: str | None = None + outcome: Outcome + action: Action + # The worst YES price accepted: the highest for a YES buy (or NO sell), + # the lowest for a YES sell (or NO buy). A quote worse than it is refused + # before anything is sent. + worst_price: Decimal | None = None + quantity: Decimal | None = None + # A partial size, where the venue allows one (Kalshi FIX). Neither means + # the quote's full size. At most one. + amount: Decimal | None = None + # The idempotency key; defaults to one derived from the quote. + client_order_id: str | None = None + account: Account | None = None + book: str | None = None + trader: str | None = None + tags: dict[str, str] = Field(default_factory=dict) + + @model_validator(mode="after") + def _consistent(self) -> "AcceptQuote": + _yes_price(self.worst_price, "worst_price") + _one_size(self.quantity, self.amount, required=False) + return self + + @property + def side(self) -> Side: + """The YES-leg side taken.""" + return yes_side(self.outcome, self.action) + + def refuses(self, yes_price: Decimal) -> bool: + """Whether a YES price is worse than `worst_price`.""" + if self.worst_price is None: + return False + return yes_price > self.worst_price if self.side == Side.BUY else yes_price < self.worst_price + + +class MakerQuote(_Base): + """A quoter's answer to an incoming RFQ, in YES prices.""" + + rfq_id: str + # The YES price we buy at, if the requester sells. + bid: Decimal | None = None + # The YES price we sell at, if the requester buys. + ask: Decimal | None = None + # Contracts on each side; `None` means the RFQ's full size. + quantity: Decimal | None = None + expires_at: int | None = None + client_order_id: str | None = None + rest_remainder: bool = False + # Our order, once the quote trades, only adds liquidity (Polymarket US + # `postOnly`, participate-don't-initiate). + post_only: bool = False + account: Account | None = None + book: str | None = None + trader: str | None = None + tags: dict[str, str] = Field(default_factory=dict) + + @model_validator(mode="after") + def _consistent(self) -> "MakerQuote": + _yes_price(self.bid, "bid") + _yes_price(self.ask, "ask") + if self.bid is None and self.ask is None: + raise ValueError("quote a bid, an ask or both") + if self.bid is not None and self.ask is not None and self.bid >= self.ask: + raise ValueError(f"bid {self.bid} must be below ask {self.ask}") + _positive(self.quantity, "quantity") + return self diff --git a/synpath/types.py b/synpath/types.py index 3233e1d..9aa95a7 100644 --- a/synpath/types.py +++ b/synpath/types.py @@ -29,29 +29,27 @@ from ._deprecated import QUANTITY, rename_input, renamed_fields +# Normalized lifecycle. `native_status` always carries the venue's own word. +# +# unopened — listed but not yet accepting orders +# open — trading +# closed — trading has stopped, outcome not yet final +# settled — outcome final and paid MarketStatus = Literal["unopened", "open", "closed", "settled"] -"""Normalized lifecycle. `native_status` always carries the venue's own word. - - unopened — listed but not yet accepting orders - open — trading - closed — trading has stopped, outcome not yet final - settled — outcome final and paid -""" +# Which side of a market a book or candle series is priced for. BookSide = Literal["yes", "no"] -"""Which side of a market a book or candle series is priced for.""" +# How a venue stores the book behind a market. +# +# shared_complement — one book serves both sides (Kalshi, Polymarket US). The +# NO view is a transform of the YES book, not a second book. +# native_per_outcome — each side owns an independently addressable book +# (Polymarket: one per CLOB token). BookModel = Literal["shared_complement", "native_per_outcome"] -"""How a venue stores the book behind a market. - - shared_complement — one book serves both sides (Kalshi, Polymarket US). The - NO view is a transform of the YES book, not a second book. - native_per_outcome — each side owns an independently addressable book - (Polymarket: one per CLOB token). -""" +# Where a candle's OHLC actually came from. See `Candle`. PriceSource = Literal["trade", "sampled_mid", "sampled_last", "bid_ask_mid"] -"""Where a candle's OHLC actually came from. See `Candle`.""" T = TypeVar("T") @@ -133,19 +131,19 @@ class Quote(_Base): one side that is quoted. """ + # Best bid, 0-1. `None` when nobody is bidding. bid: float | None = None - """Best bid, 0-1. `None` when nobody is bidding.""" bid_size: float | None = None + # Best ask, 0-1. `None` when nobody is offering. ask: float | None = None - """Best ask, 0-1. `None` when nobody is offering.""" ask_size: float | None = None + # `(bid + ask) / 2`, only when both sides are quoted. mid: float | None = None - """`(bid + ask) / 2`, only when both sides are quoted.""" + # Last traded price. `None` if this instrument has never traded. last: float | None = None - """Last traded price. `None` if this instrument has never traded.""" + # When `last` traded, in ms. Always read it alongside `last`: on a thin + # market the last print can be months old. last_timestamp: int | None = None - """When `last` traded, in ms. Always read it alongside `last`: on a thin - market the last print can be months old.""" last_datetime: str | None = None @computed_field # type: ignore[prop-decorator] @@ -172,17 +170,17 @@ class Outcome(_Base): book, which `Market.book_model` records. """ + # The venue's display text ("Yes", "No", "Up", a candidate name). Never + # used to decide financial logic: which of `market.yes` / `market.no` an + # outcome sits in is. label: str - """The venue's display text ("Yes", "No", "Up", a candidate name). Never - used to decide financial logic: which of `market.yes` / `market.no` an - outcome sits in is.""" + # In this outcome's own price convention: the NO quote is what NO costs. quote: Quote = Field(default_factory=Quote) - """In this outcome's own price convention: the NO quote is what NO costs.""" + # The venue's own id for this side, where it has one. Polymarket only: + # the CLOB token id the venue's order books and orders are keyed on. venue_token_id: str | None = None - """The venue's own id for this side, where it has one. Polymarket only: - the CLOB token id the venue's order books and orders are keyed on.""" + # Absolute probability delta over 24h, when the venue publishes it. price_change_24h: float | None = None - """Absolute probability delta over 24h, when the venue publishes it.""" info: dict[str, Any] = Field(default_factory=dict) @@ -200,89 +198,89 @@ class MarketStats(_Base): open_interest: float | None = None volume_unit: Literal["contracts", "collateral"] | None = None liquidity_unit: Literal["contracts", "collateral"] | None = None + # When the venue says these numbers were current, in ms. as_of: int | None = None - """When the venue says these numbers were current, in ms.""" class Market(_Base): """A single settleable contract — the thing that actually resolves.""" + # Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP-C25` or + # `polymarket:2252244`. One per listing on one venue. The part after the + # colon is the venue's own id, also on `venue_market_id`. id: str - """Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP-C25` or - `polymarket:2252244`. One per listing on one venue. The part after the - colon is the venue's own id, also on `venue_market_id`.""" venue: str + # The venue's own id, unprefixed: a Kalshi ticker, a Polymarket Gamma id, + # a Polymarket US slug. venue_market_id: str - """The venue's own id, unprefixed: a Kalshi ticker, a Polymarket Gamma id, - a Polymarket US slug.""" + # The parent event's Synpath id, `venue:native`. event_id: str | None = None - """The parent event's Synpath id, `venue:native`.""" title: str + # Resolution criteria, verbatim from the venue. description: str | None = None - """Resolution criteria, verbatim from the venue.""" slug: str | None = None + # The YES side, by position in the venue's payload rather than by label + # text: some Kalshi markets label both sides identically. yes: Outcome - """The YES side, by position in the venue's payload rather than by label - text: some Kalshi markets label both sides identically.""" no: Outcome status: MarketStatus + # The venue's own status word, untranslated. native_status: str | None = None - """The venue's own status word, untranslated.""" + # Accepting orders right now. Derived, and deliberately separate from + # `status`: a venue can halt trading without changing lifecycle state. active: bool = False - """Accepting orders right now. Derived, and deliberately separate from - `status`: a venue can halt trading without changing lifecycle state.""" market_type: Literal["binary", "categorical", "scalar", "unknown"] = "binary" open_timestamp: int | None = None close_timestamp: int | None = None + # The venue's *scheduled* resolution time, not when it actually resolved. resolution_timestamp: int | None = None - """The venue's *scheduled* resolution time, not when it actually resolved.""" open_datetime: str | None = None close_datetime: str | None = None resolution_datetime: str | None = None + # Minimum price increment. Needed to place an order that will be accepted. tick_size: float | None = None - """Minimum price increment. Needed to place an order that will be accepted.""" + # What one contract pays at full settlement. Both venues pay 1.00 today. + # Every complement transform reads this rather than hardcoding 1. face_value: float = 1.0 - """What one contract pays at full settlement. Both venues pay 1.00 today. - Every complement transform reads this rather than hardcoding 1.""" book_model: BookModel = "native_per_outcome" stats: MarketStats = Field(default_factory=MarketStats) url: str | None = None image_url: str | None = None category: str | None = None tags: list[str] = Field(default_factory=list) + # The venue's recurring-series tier above the event. Kalshi keys its fee + # schedule on this. `None` on venues without the concept. series_id: str | None = None - """The venue's recurring-series tier above the event. Kalshi keys its fee - schedule on this. `None` on venues without the concept.""" + # This market's short name inside its event. + # + # An event like "Fed Decision in September?" holds one market per outcome, + # and `title` is the whole question ("Will the Fed decrease interest rates by + # 50+ bps after the September meeting?") while this is the label the venue + # lists it under ("50+ bps decrease"). Kalshi publishes it as the YES side's + # subtitle, Polymarket as the market's group item title. Useful for display, + # and for deciding whether two venues are offering the same option. outcome_label: str | None = None - """This market's short name inside its event. - - An event like "Fed Decision in September?" holds one market per outcome, - and `title` is the whole question ("Will the Fed decrease interest rates by - 50+ bps after the September meeting?") while this is the label the venue - lists it under ("50+ bps decrease"). Kalshi publishes it as the YES side's - subtitle, Polymarket as the market's group item title. Useful for display, - and for deciding whether two venues are offering the same option.""" + # Whether this market belongs to a group where a NO position converts into + # YES exposure on the others. `None` means unknown, never assumed False. neg_risk: bool | None = None - """Whether this market belongs to a group where a NO position converts into - YES exposure on the others. `None` means unknown, never assumed False.""" + # Who decides the outcome, as `{"name", "url"}` entries. + # + # Carried because `description` says what has to happen and this says who + # rules on whether it did. Two venues can list the same question and settle + # it off different sources, which is the difference between the same trade + # and two different ones. Empty when the venue names no source. settlement_sources: list[dict[str, Any]] = Field(default_factory=list) - """Who decides the outcome, as `{"name", "url"}` entries. - - Carried because `description` says what has to happen and this says who - rules on whether it did. Two venues can list the same question and settle - it off different sources, which is the difference between the same trade - and two different ones. Empty when the venue names no source.""" info: dict[str, Any] = Field(default_factory=dict) class Event(_Base): """A real-world question domain grouping one or more markets.""" + # Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP`. id: str - """Synpath's id: `venue:native`, e.g. `kalshi:KXFEDDECISION-26SEP`.""" venue: str + # The venue's own id, unprefixed. venue_event_id: str - """The venue's own id, unprefixed.""" title: str description: str | None = None slug: str | None = None @@ -292,21 +290,21 @@ class Event(_Base): category: str | None = None tags: list[str] = Field(default_factory=list) series_id: str | None = None + # `None` is a real answer: the venue did not say. Never defaulted to False. mutually_exclusive: bool | None = None - """`None` is a real answer: the venue did not say. Never defaulted to False.""" close_timestamp: int | None = None close_datetime: str | None = None url: str | None = None image_url: str | None = None + # Who decides the outcomes under this event. On Kalshi these are published + # per event rather than per market, so a market inherits its event's. settlement_sources: list[dict[str, Any]] = Field(default_factory=list) - """Who decides the outcomes under this event. On Kalshi these are published - per event rather than per market, so a market inherits its event's.""" info: dict[str, Any] = Field(default_factory=dict) class OrderLevel(_Base): + # 0-1, in the price convention of the side the book was asked for. price: float - """0-1, in the price convention of the side the book was asked for.""" size: float @@ -319,21 +317,21 @@ class OrderBook(_Base): rather than invisible. """ + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" + # Which side this book is priced for. Ask for `side="no"` to see what NO + # costs; on a `shared_complement` venue that is the YES book mirrored. side: BookSide = "yes" - """Which side this book is priced for. Ask for `side="no"` to see what NO - costs; on a `shared_complement` venue that is the YES book mirrored.""" venue: str + # Descending by price. bids: list[OrderLevel] = Field(default_factory=list) - """Descending by price.""" + # Ascending by price. asks: list[OrderLevel] = Field(default_factory=list) - """Ascending by price.""" timestamp: int | None = None datetime: str | None = None book_model: BookModel = "native_per_outcome" + # True when these levels were mirrored from the complement's book. derived: bool = False - """True when these levels were mirrored from the complement's book.""" depth_scope: Literal["full", "top_n", "unknown"] = "unknown" info: dict[str, Any] = Field(default_factory=dict) @@ -356,18 +354,18 @@ class Trade(_Base): """One execution, from the taker's point of view.""" id: str + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" timestamp: int datetime: str + # Always the YES price, 0-1. A trade where the taker bought NO at 0.30 is + # reported as `price=0.70, side="sell"`. price: float - """Always the YES price, 0-1. A trade where the taker bought NO at 0.30 is - reported as `price=0.70, side="sell"`.""" + # Contracts traded. quantity: float - """Contracts traded.""" + # What the taker did on the YES leg: `buy` took YES, `sell` took NO. + # `unknown` when the venue does not say. side: Literal["buy", "sell", "unknown"] = "unknown" - """What the taker did on the YES leg: `buy` took YES, `sell` took NO. - `unknown` when the venue does not say.""" info: dict[str, Any] = Field(default_factory=dict) @@ -397,9 +395,9 @@ class HistoricalTrade(Trade): """A Trade plus its recorder receive time and timestamp provenance.""" __synpath_renamed__ = {} # the hosted service's `amount` is no deprecation: read it quietly + # Contracts traded. The hosted history service names it `amount`; both + # names are read, and it is always written as `quantity`. quantity: float = Field(validation_alias=AliasChoices("quantity", "amount")) - """Contracts traded. The hosted history service names it `amount`; both - names are read, and it is always written as `quantity`.""" observed_at_ms: int timestamp_source: Literal["venue", "recorder_fallback"] @@ -480,39 +478,37 @@ class Candle(_Base): are holding. """ + # Start of the bar, in ms. timestamp: int - """Start of the bar, in ms.""" datetime: str open: float | None = None high: float | None = None low: float | None = None close: float | None = None + # `None` when the venue publishes no volume for the bar, which is not the + # same as zero volume. volume: float | None = None - """`None` when the venue publishes no volume for the bar, which is not the - same as zero volume.""" trade_count: int | None = None + # trade — built from executions in the period + # bid_ask_mid — no trades; midpoint of the venue's bid/ask bars + # sampled_mid — the venue published price samples, not bars, and these were + # bucketed by this library (Polymarket) + # sampled_last — the same, but the samples are the last traded price, not a + # midpoint: a period without trades repeats the last one (Opinion) price_source: PriceSource = "trade" - """ - trade — built from executions in the period - bid_ask_mid — no trades; midpoint of the venue's bid/ask bars - sampled_mid — the venue published price samples, not bars, and these were - bucketed by this library (Polymarket) - sampled_last — the same, but the samples are the last traded price, not a - midpoint: a period without trades repeats the last one (Opinion) - """ bid_close: float | None = None + # Book state at the close of the bar, where the venue reports it. ask_close: float | None = None - """Book state at the close of the bar, where the venue reports it.""" info: dict[str, Any] = Field(default_factory=dict) +# Kalshi's quadratic fee types: the maker fee as a share of the taker +# coefficient (0.07). A plain `quadratic` series charges makers nothing. KALSHI_MAKER_SHARE: dict[str, float] = { "quadratic": 0.0, "quadratic_with_maker_fees": 0.25, "quadratic_with_combo_maker_fees": 0.5, } -"""Kalshi's quadratic fee types: the maker fee as a share of the taker -coefficient (0.07). A plain `quadratic` series charges makers nothing.""" class FeeSchedule(_Base): @@ -526,19 +522,19 @@ class FeeSchedule(_Base): venue: str scope: Literal["venue", "series", "market"] scope_id: str + # Kalshi: `quadratic` — fee per contract is `multiplier * p * (1 - p)`, + # which peaks at 50c and vanishes at the extremes. fee_type: str - """Kalshi: `quadratic` — fee per contract is `multiplier * p * (1 - p)`, - which peaks at 50c and vanishes at the extremes.""" multiplier: float | None = None maker_rate: float | None = None taker_rate: float | None = None rounding: str | None = None + # Polymarket: the power applied to `P * (1 - P)`. Every live market + # publishes `1`; `None` means 1. exponent: float | None = None - """Polymarket: the power applied to `P * (1 - P)`. Every live market - publishes `1`; `None` means 1.""" + # Smallest fee one taker order is charged, in collateral, where the venue + # has a floor (Opinion: 0.25 USDT). `None` means no floor. min_fee: float | None = None - """Smallest fee one taker order is charged, in collateral, where the venue - has a floor (Opinion: 0.25 USDT). `None` means no floor.""" info: dict[str, Any] = Field(default_factory=dict) def estimate(self, price: float, contracts: float, *, taker: bool = True) -> float | None: @@ -627,11 +623,11 @@ def estimate(self, price: float, contracts: float, *, taker: bool = True) -> flo LIMITLESS_FEE_CEILING = 0.03 +# Limitless's published buy-fee points: (price, rate). Flat at 3% below 50c. LIMITLESS_BUY_CURVE: tuple[tuple[float, float], ...] = ( (0.50, 0.0300), (0.55, 0.0252), (0.60, 0.0213), (0.65, 0.0180), (0.70, 0.0151), (0.75, 0.0126), (0.80, 0.0105), (0.85, 0.0085), (0.90, 0.0068), (0.95, 0.0053), (0.99, 0.0042), (0.999, 0.0040), ) -"""Limitless's published buy-fee points: (price, rate). Flat at 3% below 50c.""" def limitless_buy_rate(price: float) -> float: @@ -655,26 +651,26 @@ class MarketLink(_Base): or "not_same" this library hands down. See `match_market`. """ + # A Synpath id (`polymarket:2252244`) -- the other venue's listing. id: str - """A Synpath id (`polymarket:2252244`) -- the other venue's listing.""" venue: str + # Which side of *this* link is which side of the anchor: `{"yes": "yes", + # "no": "no"}` when the two agree, `{"yes": "no", "no": "yes"}` when the + # venues put the proposition on opposite sides (Kalshi's "Mashtakov wins?" + # YES is Polymarket's "Pieczonka / Mashtakov" NO). side_map: dict[BookSide, BookSide] - """Which side of *this* link is which side of the anchor: `{"yes": "yes", - "no": "no"}` when the two agree, `{"yes": "no", "no": "yes"}` when the - venues put the proposition on opposite sides (Kalshi's "Mashtakov wins?" - YES is Polymarket's "Pieczonka / Mashtakov" NO).""" class MarketMatch(_Base): """The answer to "what is this market on the other venue?" -- see `match_market`.""" + # The Synpath id the query was anchored on. anchor: str - """The Synpath id the query was anchored on.""" + # The canonical event both sides of a match belong to, when the anchor parsed onto one. event_id: str | None = None - """The canonical event both sides of a match belong to, when the anchor parsed onto one.""" + # `None` means the anchor is a real listing with nothing on the other + # venue asking the same question -- not "unknown", not "no such market". matched: MarketLink | None = None - """`None` means the anchor is a real listing with nothing on the other - venue asking the same question -- not "unknown", not "no such market".""" class EventMatch(_Base): @@ -686,11 +682,11 @@ class EventMatch(_Base): """ anchor: str + # The canonical event(s) the anchor belongs to. Usually one. event_ids: list[str] = Field(default_factory=list) - """The canonical event(s) the anchor belongs to. Usually one.""" + # Venue name -> its native event ids on the same canonical event(s), or + # `None` when that venue lists nothing there. events: dict[str, list[str] | None] = Field(default_factory=dict) - """Venue name -> its native event ids on the same canonical event(s), or - `None` when that venue lists nothing there.""" class Series(_Base): diff --git a/synpath/ws/__init__.py b/synpath/ws/__init__.py index 6260aee..fbb71c5 100644 --- a/synpath/ws/__init__.py +++ b/synpath/ws/__init__.py @@ -47,7 +47,7 @@ def __getattr__(name: str): if name in ("LimitlessMarketStream", "LimitlessUserStream"): from . import limitless return getattr(limitless, name) - if name in ("SxBetMarketStream", "SxBetUserStream"): + if name in ("SxBetMarketStream", "SxBetUserStream", "SxBetParlayStream"): from . import sx_bet return getattr(sx_bet, name) if name == "BetfairMarketStream": @@ -74,6 +74,6 @@ def __getattr__(name: str): "PolymarketUSExchangeInstrumentStream", "PolymarketUSExchangePositionStream", "PolymarketUSExchangeMarketDataStream", "PolymarketUSExchangeBalanceLedgerStream", "OpinionMarketStream", "OpinionUserStream", "HyperliquidMarketStream", "HyperliquidUserStream", - "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", + "PredictFunMarketStream", "PredictFunUserStream", "LimitlessMarketStream", "LimitlessUserStream", "SxBetMarketStream", "SxBetUserStream", "SxBetParlayStream", "BetfairMarketStream", "SmarketsMarketStream", ] diff --git a/synpath/ws/base.py b/synpath/ws/base.py index ef0d02a..415710c 100644 --- a/synpath/ws/base.py +++ b/synpath/ws/base.py @@ -45,6 +45,18 @@ ONE = Decimal("1") +def decode_json_or_text(raw: str) -> Any: + """Decode JSON while preserving text frames such as heartbeat responses. + + This is an optional utility for adapters, not a parsing policy imposed by + Stream. Wire protocols such as Socket.IO use their own frame decoder. + """ + try: + return json.loads(raw) + except ValueError: + return raw + + def now_ms() -> int: return int(time.time() * 1000) @@ -56,8 +68,8 @@ def now_ms() -> int: @dataclass(frozen=True, slots=True) class BookLevel: price: Decimal + # For a delta, the level's size after the change; zero means removed. size: Decimal - """For a delta, the level's size after the change; zero means removed.""" @dataclass(frozen=True, slots=True, kw_only=True) @@ -73,8 +85,8 @@ class BookEvent(Event): the terms of `side`: a NO book is what NO costs. `best_bid` and `best_ask` are the local book's after the change.""" + # Synpath id, `venue:native`. market_id: str - """Synpath id, `venue:native`.""" side: Literal["yes", "no"] = "yes" kind: Literal["snapshot", "delta"] bids: tuple[BookLevel, ...] = () @@ -266,8 +278,8 @@ def mirrored(self, face_value: Decimal = ONE) -> "PyLocalBook": return other +# The book every stream keeps: the Rust core's when installed, else `PyLocalBook`. LocalBook = _native.pick("LocalBook", PyLocalBook) -"""The book every stream keeps: the Rust core's when installed, else `PyLocalBook`.""" def D(value: Any) -> Decimal: @@ -288,8 +300,10 @@ def maybe_D(value: Any) -> Decimal | None: # Stream # --------------------------------------------------------------------------- -Connector = Callable[..., Awaitable[Any]] +WebSocketConnectionOpener = Callable[..., Awaitable[Any]] +# A unique queue marker, not a venue event. close() adds it after pending +# events so events() can stop instead of waiting forever for another message. _CLOSED = object() @@ -308,25 +322,30 @@ class Stream: """One venue WebSocket, kept alive, turned into events. Subclasses provide the endpoint (`url`, `headers()`), what to send after - connecting (`on_connect()`), how to read a message (`handle()`), and what + connecting (`on_connect()`), how to process a frame (`handle_raw()`), and what to forget when the connection drops (`on_disconnect()`). Subscribing is recording an intent: it is sent now if connected and again after every reconnect. + + websocket_connection_opener optionally supplies an async function that + opens a socket from the endpoint URL and additional_headers. It is called + for every connection attempt, including reconnects. Tests can supply + scripted sockets without replacing the venue adapter or receiver loop. """ venue: str = "" name: str = "stream" has: dict[str, Capability] = {} + # Carries account data the venue cannot replay after a disconnect. private: bool = False - """Carries account data the venue cannot replay after a disconnect.""" + # A text frame the venue wants as its heartbeat, sent every `ping_interval`. app_ping: str | None = None - """A text frame the venue wants as its heartbeat, sent every `ping_interval`.""" + # Whether the venue's keepalive arrives as data this stream can see (a + # `PONG` text frame, a heartbeat message). Only then does silence mean a + # dead connection: a venue that keeps the socket alive with protocol ping + # frames can be quiet for minutes on a slow market, and there the + # WebSocket library's own ping/pong is what detects a dead peer. data_heartbeat: bool = False - """Whether the venue's keepalive arrives as data this stream can see (a - `PONG` text frame, a heartbeat message). Only then does silence mean a - dead connection: a venue that keeps the socket alive with protocol ping - frames can be quiet for minutes on a slow market, and there the - WebSocket library's own ping/pong is what detects a dead peer.""" def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) @@ -336,7 +355,7 @@ def __init__( self, url: str, *, - connect: Connector | None = None, + websocket_connection_opener: WebSocketConnectionOpener | None = None, ping_interval: float = 10.0, idle_timeout: float | None | Literal["auto"] = "auto", backoff_initial: float = 0.5, @@ -344,18 +363,32 @@ def __init__( max_queue: int = 0, ): self.url = url - self._connect = connect + # Optional async function used instead of the normal WebSocket opener. + # Called on every connection attempt, including reconnects; tests can + # return scripted sockets while exercising the real stream lifecycle. + self._websocket_connection_opener = websocket_connection_opener self.ping_interval = ping_interval self.idle_timeout = (60.0 if self.data_heartbeat else None) if idle_timeout == "auto" else idle_timeout self.backoff_initial = backoff_initial self.backoff_max = backoff_max + # The receiver puts interpreted events here; events() removes them. + # One queue delivers each item to one reader, rather than broadcasting. self._queue: asyncio.Queue = asyncio.Queue(maxsize=max_queue) self._last_seen = 0 self._stale = "" self._ws: Any = None - self._task: asyncio.Task | None = None + # Background connection/receive/reconnect loop created by start(). + # Runs _run_connection_and_receive_loop(), not the caller's strategy loop. + # None means no task has been created, or close() has cleared it. + self._receiver_task: asyncio.Task | None = None + # A deliberate shutdown must stop reconnecting and prevent events() + # from automatically starting another receiver. self._closing = False + # Named status operations own lifecycle counters; frame reception owns + # message counts/timestamps. Neither establishes book readiness. self.stats = StreamStats() + # A waitable signal for an open connection. Set before on_connect(), + # so it does not establish subscription completion or book readiness. self.connected = asyncio.Event() # -- venue hooks ---------------------------------------------------------- @@ -370,22 +403,32 @@ async def on_connect(self) -> None: def on_disconnect(self) -> None: """Forget connection-scoped state: subscription ids, sequence numbers, book readiness.""" - def handle(self, message: Any) -> list[Event]: - """One decoded message as events.""" - return [] + def handle_raw(self, raw: str) -> list[Event]: + """Process one complete frame and return its SDK events, possibly none. - def handle_raw(self, raw: str) -> list[Event] | None: - """One undecoded frame as events, by the Rust core, or `None` to have - `handle` read it instead. A stream overrides this where the core - handles its messages; whatever the core declines goes to `handle`.""" - return None + The venue adapter owns framing, decoding, derived state updates and + any native/Python selection. It must return a list, never None. + """ + raise NotImplementedError("WebSocket adapters must implement handle_raw()") # -- lifecycle ------------------------------------------------------------ def start(self) -> "Stream": - if self._task is None or self._task.done(): + """Schedule the background receiver without waiting for a connection. + + Call inside a running asyncio event loop. Repeated calls share a + running receiver task; a missing or finished task is replaced. + Connection failures are retried without a configured attempt limit + until shutdown. Returning self supports async context-manager entry. + + This does not wait for connection, subscription completion or a ready + book. Consumers must use the appropriate connection/readiness signals. + """ + if self._receiver_task is None or self._receiver_task.done(): self._closing = False - self._task = asyncio.get_running_loop().create_task(self._run(), name=f"synpath-{self.venue}-{self.name}") + self._receiver_task = asyncio.get_running_loop().create_task( + self._run_connection_and_receive_loop(), name=f"synpath-{self.venue}-{self.name}", + ) return self async def close(self) -> None: @@ -396,31 +439,45 @@ async def close(self) -> None: await ws.close() except Exception: # pragma: no cover - closing a dead socket pass - if self._task is not None: - self._task.cancel() + if self._receiver_task is not None: + self._receiver_task.cancel() try: - await self._task + await self._receiver_task except (asyncio.CancelledError, Exception): pass - self._task = None + self._receiver_task = None self._queue.put_nowait(_CLOSED) async def __aenter__(self) -> "Stream": + """Enter `async with stream`: start receiving and bind self after `as`.""" return self.start() async def __aexit__(self, *exc: Any) -> None: + """Leave `async with`: close even if the block raises an exception. + + Returning None leaves the exception to propagate to the caller. + """ await self.close() def __aiter__(self) -> AsyncIterator[Event]: + """Give `async for event in stream` its asynchronous event iterator.""" return self.events() async def events(self) -> AsyncIterator[Event]: - """Every event, in the order it was produced, until `close()`. Starts - the stream if it has not been started; a closed stream stays closed.""" - if self._task is None and not self._closing: + """Yield queued events in production order, waiting when none are ready. + + Start the background receiver if it has not been started. After + close(), do not automatically restart it; finish when the shutdown + marker reaches this reader. Queue items are removed, not broadcast. + """ + # Allow direct iteration without requiring a separate start() call. + if self._receiver_task is None and not self._closing: self.start() while True: + # Waiting suspends this consumer, allowing the receiver and other + # coroutines to run. The returned item has been removed from the queue. item = await self._queue.get() + # Identity distinguishes our shutdown marker from every real event. if item is _CLOSED: return yield item @@ -428,13 +485,58 @@ async def events(self) -> AsyncIterator[Event]: def emit(self, event: Event) -> None: self._queue.put_nowait(event) - def status(self, state: StreamState, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: - if state == "gap": - self.stats.gaps += 1 - elif state == "resynced": - self.stats.resyncs += 1 - elif state == "error": - self.stats.errors += 1 + # -- status operations ---------------------------------------------------- + + def _record_connection_opened_and_emit_status(self, *, reconcile_on_reconnect: bool | None = None) -> None: + """Count one established connection, wake waiters and publish its status. + + The caller decides when the transport is established: an open socket + for WebSockets, the first response/headers for gRPC. This does not + establish subscription completion or book readiness. + """ + reconnect = self.stats.connects > 0 + self.stats.connects += 1 + self.connected.set() + needs_reconciliation = self.private if reconcile_on_reconnect is None else reconcile_on_reconnect + self._emit_status_event( + "connected", "reconnected" if reconnect else "", reconcile=reconnect and needs_reconciliation, + ) + + def _record_connection_closed_and_emit_status(self, detail: str, *, notify_consumers: bool = True) -> None: + """Clear the connection, count its end and invalidate venue state before notifying. + + A deliberate shutdown or cancelled receiver still records the end and + runs on_disconnect(), but the caller suppresses the disconnect event. + Only call this for a connection previously recorded as established. + """ + self._ws = None + self.connected.clear() + self.stats.disconnects += 1 + self.on_disconnect() + if notify_consumers: + self._emit_status_event("disconnected", detail) + + def _record_gap_and_emit_status(self, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: + """Count and report missing data; the venue adapter owns its recovery policy.""" + self.stats.gaps += 1 + self._emit_status_event("gap", detail, key=key, reconcile=reconcile) + + def _record_resync_and_emit_status(self, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: + """Count and report a recovery already performed by the venue adapter.""" + self.stats.resyncs += 1 + self._emit_status_event("resynced", detail, key=key, reconcile=reconcile) + + def _record_error_and_emit_status(self, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: + """Count and report an error without changing connection or book readiness.""" + self.stats.errors += 1 + self._emit_status_event("error", detail, key=key, reconcile=reconcile) + + def _emit_status_event(self, state: StreamState, detail: str = "", *, key: str | None = None, reconcile: bool = False) -> None: + """Construct and enqueue a status event without mutating lifecycle state or counters. + + Venue subclasses can redact sensitive details here so all status + operations, including connection failures, use the same protection. + """ self.emit(StreamStatusEvent( venue=self.venue, stream=self.name, state=state, detail=detail, key=key, reconcile_required=reconcile, )) @@ -448,12 +550,17 @@ async def send(self, frame: Any) -> bool: return True def backoff(self, attempt: int) -> float: + """Calculate a randomized retry delay in seconds; do not sleep. + + attempt is the zero-based consecutive failure index. backoff_max caps + the delay, not the number of retries; there is no retry budget here. + """ base = min(self.backoff_max, self.backoff_initial * (2 ** attempt)) return base * (0.5 + random.random() / 2) async def _open(self) -> Any: - if self._connect is not None: - return await self._connect(self.url, additional_headers=self.headers()) + if self._websocket_connection_opener is not None: + return await self._websocket_connection_opener(self.url, additional_headers=self.headers()) import websockets return await websockets.connect( @@ -461,40 +568,48 @@ async def _open(self) -> Any: ping_interval=None if self.app_ping else 20, max_size=None, ) - async def _run(self) -> None: + async def _run_connection_and_receive_loop(self) -> None: + """Maintain a connection and process incoming frames until shutdown.""" attempt = 0 + # The outer loop manages successive connections. Failed opens retry + # without an attempt limit; shutdown/cancellation stops the receiver. while not self._closing: try: ws = await self._open() except asyncio.CancelledError: raise except Exception as exc: - self.status("connect_failed", f"{type(exc).__name__}: {exc}") + self._emit_status_event("connect_failed", f"{type(exc).__name__}: {exc}") await asyncio.sleep(self.backoff(attempt)) attempt += 1 continue attempt = 0 + # Record an open transport and notify waiters/consumers. Subscription + # setup happens below, so "connected" does not mean market data is ready. self._ws = ws - reconnect = self.stats.connects > 0 - self.stats.connects += 1 - self.connected.set() - self.status("connected", "reconnected" if reconnect else "", reconcile=reconnect and self.private) + self._record_connection_opened_and_emit_status() pinger = watchdog = None + receiver_cancelled = False reason = "closed" self._last_seen, self._stale = now_ms(), "" try: + # Subclass defined behaviour (e.g. send subscription message) after connection is established await self.on_connect() if self.app_ping: pinger = asyncio.get_running_loop().create_task(self._pinger(ws)) if self.idle_timeout: watchdog = asyncio.get_running_loop().create_task(self._watchdog(ws)) + # The inner loop receives successive frames on one connection. + # A receive/setup failure leaves this connection's scope and + # returns control to the outer connection loop after cleanup. while not self._closing: if self.idle_timeout: raw = await asyncio.wait_for(ws.recv(), timeout=self.idle_timeout) else: raw = await ws.recv() - self._dispatch(raw) + self._process_raw_frame_and_emit_events(raw) except asyncio.CancelledError: + receiver_cancelled = True raise except asyncio.TimeoutError: reason = f"no message for {self.idle_timeout}s" @@ -504,13 +619,11 @@ async def _run(self) -> None: for task in (pinger, watchdog): if task: task.cancel() - self._ws = None - self.connected.clear() - self.stats.disconnects += 1 - self.on_disconnect() + self._record_connection_closed_and_emit_status( + reason, notify_consumers=not self._closing and not receiver_cancelled, + ) if self._closing: return - self.status("disconnected", reason) try: await ws.close() except Exception: @@ -544,24 +657,22 @@ async def _pinger(self, ws: Any) -> None: await asyncio.sleep(self.ping_interval) await ws.send(self.app_ping) - def _dispatch(self, raw: Any) -> None: + def _process_raw_frame_and_emit_events(self, raw: Any) -> None: + """Process one received frame, update venue state and enqueue its events. + + A frame can produce zero, one or several events. The adapter's + handle_raw() owns parsing, implementation selection and derived state + such as cached books. This method counts receipt, enqueues the returned + events and reports processing errors as status events for consumers. + """ self.stats.messages += 1 self._last_seen = now_ms() self.stats.last_message_at = now_ms() if isinstance(raw, bytes): raw = raw.decode("utf-8") try: - fast = self.handle_raw(raw) if isinstance(raw, str) else None - if fast is not None: - for event in fast: - self.emit(event) - return - try: - message = json.loads(raw) - except ValueError: - message = raw - for event in self.handle(message): + for event in self.handle_raw(raw): self.emit(event) except Exception as exc: log.debug("synpath.ws %s %s: unreadable message %r", self.venue, self.name, raw, exc_info=True) - self.status("error", f"unreadable message: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"unreadable message: {type(exc).__name__}: {exc}") diff --git a/synpath/ws/betfair.py b/synpath/ws/betfair.py index 8159b26..49e7ee4 100644 --- a/synpath/ws/betfair.py +++ b/synpath/ws/betfair.py @@ -46,7 +46,7 @@ from .. import ids from ..base import Capability from ..errors import AuthenticationError -from .base import BookEvent, BookLevel, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms +from .base import BookEvent, BookLevel, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms, decode_json_or_text log = logging.getLogger("synpath.ws.betfair") @@ -54,21 +54,21 @@ STREAM_HOST = "stream-api.betfair.com" STREAM_PORT = 443 +# Markets one connection may subscribe, by default, before Betfair refuses +# with SUBSCRIPTION_LIMIT_EXCEEDED. MAX_MARKETS = 200 -"""Markets one connection may subscribe, by default, before Betfair refuses -with SUBSCRIPTION_LIMIT_EXCEEDED.""" +# Deepest best-offers ladder Betfair streams. MAX_LADDER_LEVELS = 10 -"""Deepest best-offers ladder Betfair streams.""" +# Seconds Betfair waits for, and this stream allows for, a connection's first message. FIRST_MESSAGE_TIMEOUT = 15.0 -"""Seconds Betfair waits for, and this stream allows for, a connection's first message.""" +# What Betfair conflates a delayed key's stream to: one update per market every three minutes. DELAYED_CONFLATE_MS = 180_000 -"""What Betfair conflates a delayed key's stream to: one update per market every three minutes.""" +# Largest line read: a subscription image of many markets is one line. LINE_LIMIT = 2 ** 24 -"""Largest line read: a subscription image of many markets is one line.""" ONE = Decimal(1) PRICE_PLACES = Decimal("1e-10") @@ -140,10 +140,10 @@ def state_of(market_status: str | None, runner_status: str | None) -> tuple[str, @dataclass class RunnerCache: + # `batb`: what a taker can back at, resting lays. YES asks. back: dict[int, tuple[Decimal, Decimal]] = field(default_factory=dict) - """`batb`: what a taker can back at, resting lays. YES asks.""" + # `batl`: what a taker can lay at, resting backs. YES bids. lay: dict[int, tuple[Decimal, Decimal]] = field(default_factory=dict) - """`batl`: what a taker can lay at, resting backs. YES bids.""" @dataclass @@ -216,23 +216,23 @@ def __init__( self.delayed = delayed self._open_connection = open_connection or asyncio.open_connection self._ssl = ssl_context or ssl.create_default_context() + # Betfair market id -> its cache, kept across reconnects for the resubscribe delta. self.markets: dict[str, MarketCache] = {} - """Betfair market id -> its cache, kept across reconnects for the resubscribe delta.""" + # Runner's Synpath id -> its YES book. self.books: dict[str, LocalBook] = {} - """Runner's Synpath id -> its YES book.""" self.tickers: set[str] = set() + # Betfair market ids whose status changes are passed on; `None` while not watching. self.statuses: set[str] | None = None - """Betfair market ids whose status changes are passed on; `None` while not watching.""" + # Betfair market ids the subscription carries. self.wanted: set[str] = set() - """Betfair market ids the subscription carries.""" + # Betfair markets watched as a whole: every runner's book is kept. self.whole_markets: set[str] = set() - """Betfair markets watched as a whole: every runner's book is kept.""" self.authenticated = False self.connection_id: str | None = None self.initial_clk: str | None = None self.clk: str | None = None + # The market set `clk` belongs to: a resubscribe only resumes that one. self._clk_for: frozenset[str] = frozenset() - """The market set `clk` belongs to: a resubscribe only resumes that one.""" self._next_id = 0 self._auth_id: int | None = None self._sub_id: int | None = None @@ -290,7 +290,7 @@ def _want(self, markets: list[str]) -> list[str]: continue self.wanted.add(market) if refused: - self.status("error", f"more than {MAX_MARKETS} markets on one connection; not subscribed: {', '.join(refused)}") + self._record_error_and_emit_status(f"more than {MAX_MARKETS} markets on one connection; not subscribed: {', '.join(refused)}") return refused async def watch_order_book(self, market_ids: list[str]) -> None: @@ -375,7 +375,11 @@ async def resubscribe(self) -> None: # -- messages ------------------------------------------------------------- - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] op = message.get("op") @@ -394,19 +398,19 @@ def _on_status(self, message: dict[str, Any]) -> list[Event]: self.authenticated = True self._later(self.resubscribe()) elif message.get("id") == self._sub_id: - self.status("subscribed", f"{len(self.wanted)} markets") + self._emit_status_event("subscribed", f"{len(self.wanted)} markets") return [] code = str(message.get("errorCode") or "") detail = f"{code}: {message.get('errorMessage') or ''}".strip(": ") if code in APP_KEY_ERRORS: - self.status("failed", f"Betfair refused the application key for streaming ({detail})") + self._emit_status_event("failed", f"Betfair refused the application key for streaming ({detail})") self._later(self.close()) elif code in SESSION_ERRORS: # The next connection logs in again. self.session.invalidate() - self.status("error", f"session refused ({detail}); logging in again") + self._record_error_and_emit_status(f"session refused ({detail}); logging in again") else: - self.status("error", f"Betfair refused: {detail}") + self._record_error_and_emit_status(f"Betfair refused: {detail}") return [] def _on_market_changes(self, message: dict[str, Any]) -> list[Event]: @@ -488,7 +492,7 @@ def _book_events(self, market: str, key: tuple[int, float], runner: RunnerCache, best_bid=book.best_bid, best_ask=book.best_ask, info={"delayed": self.delayed, "clk": self.clk}, )) if recovering: - self.status("resynced", f"book {synpath_id}", key=synpath_id) + self._record_resync_and_emit_status(f"book {synpath_id}", key=synpath_id) if synpath_id in self.tickers: best_bid = max(bids, default=None, key=lambda level: level[0]) best_ask = min(asks, default=None, key=lambda level: level[0]) diff --git a/synpath/ws/grpc.py b/synpath/ws/grpc.py index 9a190d7..f1a5b40 100644 --- a/synpath/ws/grpc.py +++ b/synpath/ws/grpc.py @@ -31,16 +31,16 @@ from pathlib import Path from typing import Any, AsyncIterator, Awaitable, Callable, Literal -from .base import _CLOSED, Stream, log, now_ms +from .base import _CLOSED, Event, Stream, log, now_ms PROTO_ENV = "SYNPATH_POLYMARKET_US_PROTOS" +# `(method, request, metadata) -> responses`: how a stream opens one call. +# The default speaks gRPC; tests pass scripted responses. CallOpener = Callable[[str, dict[str, Any], list[tuple[str, str]]], Awaitable[AsyncIterator[Any]]] -"""`(method, request, metadata) -> responses`: how a stream opens one call. -The default speaks gRPC; tests pass scripted responses.""" +# Yielded by a call once the server has answered (its headers arrived). READY = object() -"""Yielded by a call once the server has answered (its headers arrived).""" Outcome = Literal["retry", "reauthenticate", "slow_down", "fatal"] @@ -297,8 +297,8 @@ def __init__( **kwargs: Any, ): super().__init__(target, **kwargs) + # Plaintext, for a local test server only. self.insecure = insecure - """Plaintext, for a local test server only.""" self.protos = protos self._call_opener = call self.keepalive_s = keepalive_s @@ -310,6 +310,10 @@ def __init__( # -- venue hooks ---------------------------------------------------------- + def handle(self, message: dict[str, Any]) -> list[Event]: + """Interpret one decoded RPC message; separate from WebSocket frames.""" + return [] + def request(self) -> dict[str, Any] | None: """The request for the next call; `None` waits for `resubscribe()`.""" return {} @@ -429,17 +433,17 @@ async def _consume(self, responses: AsyncIterator[Any], on_ready: Callable[[], N events = self.handle(message) except Exception as exc: log.debug("synpath.ws %s %s: unreadable message %r", self.venue, self.name, item, exc_info=True) - self.status("error", f"unreadable message: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"unreadable message: {type(exc).__name__}: {exc}") continue for event in events: self.emit(event) def _fail(self, detail: str) -> None: self._closing = True - self.status("failed", detail) + self._emit_status_event("failed", detail) self._queue.put_nowait(_CLOSED) - async def _run(self) -> None: + async def _run_connection_and_receive_loop(self) -> None: attempt = 0 while not self._closing: self._resubscribe.clear() @@ -450,7 +454,7 @@ async def _run(self) -> None: except asyncio.CancelledError: raise except Exception as exc: - self.status("connect_failed", f"{type(exc).__name__}: {exc}") + self._emit_status_event("connect_failed", f"{type(exc).__name__}: {exc}") await self._sleep(self.backoff(attempt)) attempt += 1 continue @@ -467,7 +471,7 @@ async def _run(self) -> None: except Exception as exc: code = status_code(exc) self.last_status = code - self.status("connect_failed", f"{code or type(exc).__name__}: {status_details(exc) if code else exc}") + self._emit_status_event("connect_failed", f"{code or type(exc).__name__}: {status_details(exc) if code else exc}") if await self._after_failure(code, f"{code}: {status_details(exc)}", attempt): return attempt += 1 @@ -481,10 +485,9 @@ def ready() -> None: return confirmed = True attempt = 0 - reconnect = self.stats.connects > 0 - self.stats.connects += 1 - self.connected.set() - self.status("connected", "reconnected" if reconnect else "", reconcile=reconnect and self.reconcile_on_reconnect()) + self._record_connection_opened_and_emit_status( + reconcile_on_reconnect=self.reconcile_on_reconnect(), + ) self._last_seen, self._stale = now_ms(), "" loop = asyncio.get_running_loop() @@ -495,6 +498,7 @@ def ready() -> None: code: str | None = None reason = "closed" resubscribing = False + receiver_cancelled = False try: done, _ = await asyncio.wait(watched, return_when=asyncio.FIRST_COMPLETED) if reader in done: @@ -506,6 +510,9 @@ def ready() -> None: else: resubscribing = True reason = "resubscribing" + except asyncio.CancelledError: + receiver_cancelled = True + raise finally: for task in (reader, waker, watching): if task is not None and not task.done(): @@ -522,18 +529,22 @@ def ready() -> None: pass self._grpc_call = None if confirmed: - self.connected.clear() - self.stats.disconnects += 1 - self.on_disconnect() + # Preserve the last status visible when publishing a normal + # disconnect; shutdown/cancellation previously left it alone. + if not self._closing and not receiver_cancelled: + self.last_status = code + self._record_connection_closed_and_emit_status( + reason, notify_consumers=not self._closing and not receiver_cancelled, + ) + else: + # A call refused before its first response was never an + # established connection, but may still hold venue state. + self.on_disconnect() if self._closing: return self.last_status = code - # A call the venue refused before answering was never a connection, - # and one replaced before it answered was neither. - if confirmed: - self.status("disconnected", reason) - elif not resubscribing: - self.status("connect_failed", reason) + if not confirmed and not resubscribing: + self._emit_status_event("connect_failed", reason) if resubscribing: continue if await self._after_failure(code, reason, attempt): diff --git a/synpath/ws/hyperliquid.py b/synpath/ws/hyperliquid.py index c754480..9b517fc 100644 --- a/synpath/ws/hyperliquid.py +++ b/synpath/ws/hyperliquid.py @@ -37,6 +37,7 @@ from ..trading.hyperliquid import fill_of, order_of, outcome_coin from ..trading.types import Account, Side from .base import ( + decode_json_or_text, BookEvent, BookLevel, Event, FillEvent, LocalBook, OrderEvent, QuoteEvent, Stream, TradeEvent, VenueEvent, maybe_D, now_ms, ) @@ -73,9 +74,9 @@ class _HyperliquidStream(Stream): def __init__(self, *, testnet: bool = False, url: str | None = None, ping_interval: float = 30.0, **kwargs: Any): super().__init__(url or (TESTNET_WS_URL if testnet else WS_URL), ping_interval=ping_interval, **kwargs) + # Every subscription asked for, keyed for de-duplication, sent again + # on every connect. self.subscriptions: dict[str, dict[str, Any]] = {} - """Every subscription asked for, keyed for de-duplication, sent again - on every connect.""" async def _subscribe(self, subscription: dict[str, Any]) -> None: key = repr(sorted(subscription.items())) @@ -98,10 +99,10 @@ def _common(self, channel: Any, message: dict[str, Any]) -> list[Event] | None: if channel in ("pong", "subscriptionResponse"): if channel == "subscriptionResponse": sub = (message.get("data") or {}).get("subscription") or {} - self.status("subscribed", str(sub.get("type") or ""), key=str(sub.get("coin") or sub.get("user") or "")) + self._emit_status_event("subscribed", str(sub.get("type") or ""), key=str(sub.get("coin") or sub.get("user") or "")) return [] if channel == "error": - self.status("error", f"venue: {message.get('data')}") + self._record_error_and_emit_status(f"venue: {message.get('data')}") return [] return None @@ -129,13 +130,13 @@ class HyperliquidMarketStream(_HyperliquidStream): def __init__(self, **kwargs: Any): super().__init__(**kwargs) + # YES coin -> its book. self.books: dict[str, LocalBook] = {} - """YES coin -> its book.""" + # YES coin -> Synpath market id. self.markets: dict[str, str] = {} - """YES coin -> Synpath market id.""" + # YES coin -> when its prints were last subscribed, in ms: the + # replayed prints before it are dropped. self.trades_since: dict[str, int] = {} - """YES coin -> when its prints were last subscribed, in ms: the - replayed prints before it are dropped.""" def _track(self, market_id: str) -> str: coin = yes_coin(market_id) @@ -191,7 +192,11 @@ def on_disconnect(self) -> None: # -- reading -------------------------------------------------------------- - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] channel = message.get("channel") @@ -219,7 +224,7 @@ def _on_book(self, data: dict[str, Any]) -> list[Event]: book.replace(levels_of(rows[0] if rows else []), levels_of(rows[1] if len(rows) > 1 else [])) book.timestamp = int(data["time"]) if data.get("time") else None if recovering: - self.status("resynced", f"book {self.markets.get(coin, coin)}", key=self.markets.get(coin)) + self._record_resync_and_emit_status(f"book {self.markets.get(coin, coin)}", key=self.markets.get(coin)) bids, asks = book.levels() return [BookEvent( venue=VENUE, market_id=self.markets.get(coin, coin), side="yes", kind="snapshot", @@ -286,8 +291,8 @@ def __init__(self, *, address: str | None = None, account_name: str = "default", f"or set HYPERLIQUID_ACCOUNT_ADDRESS") self.address = found.lower() self.account = Account(venue=VENUE, name=account_name) + # Markets to report, or `None` for every outcome. self.only: set[str] | None = None - """Markets to report, or `None` for every outcome.""" async def watch_orders(self, market_ids: list[str] | None = None) -> None: """This address's orders, on every outcome or only these markets.""" @@ -309,7 +314,11 @@ def _narrow(self, market_ids: list[str] | None) -> None: def _wanted(self, market_id: str) -> bool: return self.only is None or market_id in self.only - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] channel = message.get("channel") @@ -332,7 +341,7 @@ def _on_fills(self, data: dict[str, Any]) -> list[Event]: rows = data.get("fills") or [] if data.get("isSnapshot"): outcome_rows = sum(1 for row in rows if outcome_coin(str(row.get("coin") or ""))) - self.status("subscribed", f"userFills: snapshot of {outcome_rows} recent outcome fills not replayed", + self._emit_status_event("subscribed", f"userFills: snapshot of {outcome_rows} recent outcome fills not replayed", key="userFills") return [] events: list[Event] = [] diff --git a/synpath/ws/kalshi.py b/synpath/ws/kalshi.py index e00b3d2..fb6d0b4 100644 --- a/synpath/ws/kalshi.py +++ b/synpath/ws/kalshi.py @@ -41,6 +41,7 @@ from ..trading.kalshi import KalshiSigner, fill_of, order_of, position_of from ..trading.types import Account, Side from .base import ( + decode_json_or_text, ONE, BookEvent, BookLevel, D, Event, LocalBook, MarketStatusEvent, OrderEvent, FillEvent, PositionEvent, QuoteEvent, Stream, TradeEvent, VenueEvent, maybe_D, ) @@ -53,11 +54,11 @@ } PRIVATE_CHANNELS = frozenset({"fill", "user_orders", "market_positions", "order_group_updates"}) +# Channels that take a market filter; the rest take none. MARKET_SCOPED = frozenset({"orderbook_delta", "ticker", "trade", "fill", "user_orders", "market_positions"}) -"""Channels that take a market filter; the rest take none.""" +# Ask again for a snapshot that has not arrived after this long. RESYNC_AFTER_S = 10.0 -"""Ask again for a snapshot that has not arrived after this long.""" LIFECYCLE_STATE = { "created": "created", @@ -76,8 +77,8 @@ class Channel: server calls it on this connection.""" name: str + # `None` means every market. tickers: set[str] | None - """`None` means every market.""" sid: int | None = None command_id: int | None = None last_seq: int | None = None @@ -227,11 +228,19 @@ def book(self, market_id: str, side: str = "yes") -> LocalBook | None: # -- reading -------------------------------------------------------------- - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: + """Process a complete frame; this adapter owns native/Python selection.""" + events = self._try_process_frame_with_rust(raw) + # An empty list means handled with no events; only None requests fallback. + if events is not None: + return events + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _try_process_frame_with_rust(self, raw: str) -> list[Event] | None: """Book snapshots and deltas, decoded and applied by the Rust core on these same books. The sequence check runs here in between, as in - `handle`. Everything else, and everything when the core is not built, - goes to `handle`.""" + `_handle_decoded_message`. Everything else, and everything when the core is not built, + goes to `_handle_decoded_message`.""" if _native.core is None: return None decoded = _native.core.kalshi_book_message(raw, self.books) @@ -249,7 +258,7 @@ def handle_raw(self, raw: str) -> list[Event] | None: _, ticker, recovering, bids, asks, best_bid, best_ask, info = done self._snapshot_requested.pop(ticker, None) if recovering: - self.status("resynced", f"orderbook {ticker}", key=ticker) + self._record_resync_and_emit_status(f"orderbook {ticker}", key=ticker) return [BookEvent( venue=VENUE, market_id=ids.qualify(VENUE, ticker), kind="snapshot", bids=bids, asks=asks, best_bid=best_bid, best_ask=best_ask, sequence=seq, info=info, @@ -260,7 +269,7 @@ def handle_raw(self, raw: str) -> list[Event] | None: best_bid=best_bid, best_ask=best_ask, sequence=seq, timestamp=stamp, info=info, )] - def handle(self, message: Any) -> list[Event]: + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] kind = message.get("type") @@ -310,7 +319,7 @@ def _on_subscribed(self, message: dict[str, Any]) -> list[Event]: return [] channel.sid = int(body["sid"]) self._by_sid[channel.sid] = channel - self.status("subscribed", channel.name, key=channel.name) + self._emit_status_event("subscribed", channel.name, key=channel.name) if channel.pending_add: added, channel.pending_add = sorted(channel.pending_add), set() self._later(self._send_command("update_subscription", { @@ -325,10 +334,10 @@ def _on_error(self, message: dict[str, Any]) -> list[Event]: channel = self._by_command.pop(message.get("id"), None) or self._by_sid.get(message.get("sid")) if code == 25 and channel is not None: # The server dropped messages for this subscription: start it again. - self.status("gap", f"{channel.name}: {detail}", key=channel.name, reconcile=channel.name in PRIVATE_CHANNELS) + self._record_gap_and_emit_status(f"{channel.name}: {detail}", key=channel.name, reconcile=channel.name in PRIVATE_CHANNELS) self._restart(channel) return [] - self.status("error", detail, key=channel.name if channel else None) + self._record_error_and_emit_status(detail, key=channel.name if channel else None) return [] def _check_sequence(self, channel: Channel, seq: int, kind: str | None) -> None: @@ -336,8 +345,7 @@ def _check_sequence(self, channel: Channel, seq: int, kind: str | None) -> None: channel.last_seq = seq if last is None or seq == last + 1: return - self.status( - "gap", f"{channel.name}: expected seq {last + 1}, got {seq}", key=channel.name, + self._record_gap_and_emit_status(f"{channel.name}: expected seq {last + 1}, got {seq}", key=channel.name, reconcile=channel.name in PRIVATE_CHANNELS, ) if channel.name == "orderbook_delta": @@ -383,7 +391,7 @@ def _on_snapshot(self, message: dict[str, Any], body: dict[str, Any], channel: C book.sequence = message.get("seq") self._snapshot_requested.pop(ticker, None) if recovering: - self.status("resynced", f"orderbook {ticker}", key=ticker) + self._record_resync_and_emit_status(f"orderbook {ticker}", key=ticker) bids, asks = book.levels() return [BookEvent( venue=VENUE, market_id=ids.qualify(VENUE, ticker), kind="snapshot", bids=bids, asks=asks, diff --git a/synpath/ws/limitless.py b/synpath/ws/limitless.py index 6a5ce10..3861c34 100644 --- a/synpath/ws/limitless.py +++ b/synpath/ws/limitless.py @@ -83,7 +83,7 @@ def __init__(self, *, url: str = WS_URL, **kwargs: Any): # -- Socket.IO framing -------------------------------------------------- - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: if raw.startswith("0"): self._later(self.send(f"40{NAMESPACE},")) return [] @@ -95,12 +95,12 @@ def handle_raw(self, raw: str) -> list[Event] | None: prefix = f"4{{}}{NAMESPACE}" if raw.startswith(prefix.format("0")): self.joined = True - self.status("subscribed", NAMESPACE) + self._emit_status_event("subscribed", NAMESPACE) self._later(self.subscribe()) return [] if raw.startswith(prefix.format("4")): detail = raw[len(prefix.format("4")) + 1:] - self.status("error", f"venue refused the connection: {detail}") + self._record_error_and_emit_status(f"venue refused the connection: {detail}") return [] if raw.startswith(prefix.format("1")): self.joined = False @@ -129,7 +129,7 @@ async def subscribe(self) -> None: def on_event(self, name: str, data: Any) -> list[Event]: if name in ("exception", "error"): detail = data.get("message") if isinstance(data, dict) else data - self.status("error", f"venue error: {detail}") + self._record_error_and_emit_status(f"venue error: {detail}") return [] def _later(self, coroutine: Any) -> None: @@ -163,8 +163,8 @@ class LimitlessMarketStream(_LimitlessStream): def __init__(self, **kwargs: Any): super().__init__(**kwargs) + # Slug -> its YES book. self.books: dict[str, LocalBook] = {} - """Slug -> its YES book.""" self.tickers: set[str] = set() self.statuses: set[str] = set() self._versions: dict[str, int] = {} @@ -246,7 +246,7 @@ def _on_book(self, data: dict[str, Any]) -> list[Event]: book.replace(bids, asks) book.timestamp = stamp if recovering: - self.status("resynced", f"book {market_id}", key=market_id) + self._record_resync_and_emit_status(f"book {market_id}", key=market_id) levels_bids, levels_asks = book.levels() events.append(BookEvent( venue=VENUE, market_id=market_id, side="yes", kind="snapshot", bids=levels_bids, asks=levels_asks, @@ -287,8 +287,8 @@ def __init__(self, *, token_id: str, secret: str, catalog: Any = None, account_n self.catalog = catalog self.account = Account(venue=VENUE, name=account_name) self.watching = False + # Token id -> (market id, "yes" or "no"). self.tokens: dict[str, tuple[str, str]] = {} - """Token id -> (market id, "yes" or "no").""" self._indexed = False self._waiting: list[dict[str, Any]] = [] @@ -361,12 +361,12 @@ async def _resolve_waiting(self) -> None: self._indexed = True self.tokens.update(await asyncio.to_thread(self._catalog().token_index)) except Exception as exc: # pragma: no cover - the catalog being down - self.status("error", f"could not place order events: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"could not place order events: {type(exc).__name__}: {exc}") waiting, self._waiting = self._waiting, [] for data in waiting: placed = self._place(data) if placed is None: - self.status("error", f"order event for an unknown token: {data.get('orderId')}") + self._record_error_and_emit_status(f"order event for an unknown token: {data.get('orderId')}") continue for event in self._events(data, *placed): self.emit(event) diff --git a/synpath/ws/opinion.py b/synpath/ws/opinion.py index a987f4b..8fa629b 100644 --- a/synpath/ws/opinion.py +++ b/synpath/ws/opinion.py @@ -41,6 +41,7 @@ from ..trading.opinion import fill_of, order_of, outcome_of from ..trading.types import Account, Side from .base import ( + decode_json_or_text, ONE, BookEvent, BookLevel, Event, FillEvent, LocalBook, OrderEvent, QuoteEvent, Stream, TradeEvent, VenueEvent, ) @@ -48,9 +49,9 @@ WS_URL = "wss://ws.opinion.trade" HEARTBEAT = '{"action":"HEARTBEAT"}' +# Seconds between re-reads of every watched book. Each costs two requests +# per market against the venue's 5-a-second public REST limit. RESYNC_INTERVAL = 60.0 -"""Seconds between re-reads of every watched book. Each costs two requests -per market against the venue's 5-a-second public REST limit.""" def api_key_from(api_key: str | None) -> str: @@ -79,8 +80,8 @@ class Watched: native: str yes_token: str no_token: str + # The categorical topic's id for an option; `None` for a binary topic. topic: str | None - """The categorical topic's id for an option; `None` for a binary topic.""" def side_of(self, token: str) -> str: return "no" if token == self.no_token else "yes" @@ -103,19 +104,19 @@ def __init__( from ..opinion import Opinion catalog = Opinion() + # The read adapter: which tokens and topic a market has, and books. self.catalog = catalog - """The read adapter: which tokens and topic a market has, and books.""" self.watched: dict[str, Watched] = {} + # `(channel, "marketId" | "rootMarketId", id)`, sent again on every connect. self.subscriptions: set[tuple[str, str, int]] = set() - """`(channel, "marketId" | "rootMarketId", id)`, sent again on every connect.""" - def status(self, state: Any, detail: str = "", **kwargs: Any) -> None: + def _emit_status_event(self, state: Any, detail: str = "", **kwargs: Any) -> None: # The key travels in the URL, so it is kept out of anything reported. - super().status(state, detail.replace(self._key, "***") if detail else detail, **kwargs) + super()._emit_status_event(state, detail.replace(self._key, "***") if detail else detail, **kwargs) async def _open(self) -> Any: - if self._connect is not None: - return await self._connect(self.url, additional_headers=self.headers()) + if self._websocket_connection_opener is not None: + return await self._websocket_connection_opener(self.url, additional_headers=self.headers()) import websockets # Protocol pings as well as the venue's heartbeat frame: the heartbeat @@ -158,7 +159,7 @@ def _venue_error(self, message: dict[str, Any]) -> list[Event]: refusing something. Only the refusals are reported.""" code = message.get("code", message.get("errno", 0)) if message.get("error") or (isinstance(code, int) and code not in (0, 200)): - self.status("error", f"venue: {message}") + self._record_error_and_emit_status(f"venue: {message}") return [] @@ -177,14 +178,14 @@ class OpinionMarketStream(_OpinionStream): def __init__(self, *, resync_interval: float | None = RESYNC_INTERVAL, **kwargs: Any): super().__init__(**kwargs) self.resync_interval = resync_interval + # Token id -> its book. Both sides are read, not mirrored. self.books: dict[str, LocalBook] = {} - """Token id -> its book. Both sides are read, not mirrored.""" self.tokens: dict[str, Watched] = {} self.depth: set[str] = set() self.trades: set[str] = set() self.prices: set[str] = set() + # Token -> changes that arrived while its snapshot was being read. self._pending: dict[str, list[dict[str, Any]]] = {} - """Token -> changes that arrived while its snapshot was being read.""" self._resyncer: asyncio.Task | None = None async def watch_order_book(self, market_ids: list[str]) -> None: @@ -290,7 +291,7 @@ async def _snapshot(self, watched: Watched, *, check: bool = False) -> None: except Exception as exc: for token in tokens: self._pending.pop(token, None) - self.status("error", f"book {watched.market_id}: snapshot failed: {type(exc).__name__}: {exc}", + self._record_error_and_emit_status(f"book {watched.market_id}: snapshot failed: {type(exc).__name__}: {exc}", key=watched.market_id) return for token, side, snapshot in zip(tokens, ("yes", "no"), raws): @@ -303,8 +304,7 @@ async def _snapshot(self, watched: Watched, *, check: bool = False) -> None: recovering = not book.ready and book.timestamp is not None if check and book.ready and not pending: if dict(bids) != book.bids or dict(asks) != book.asks: - self.status( - "gap", f"book {watched.market_id} {side}: local top {book.best_bid}/{book.best_ask} " + self._record_gap_and_emit_status(f"book {watched.market_id} {side}: local top {book.best_bid}/{book.best_ask} " f"differs from the venue's", key=watched.market_id, ) recovering = True @@ -315,7 +315,7 @@ async def _snapshot(self, watched: Watched, *, check: bool = False) -> None: for change in pending: self._apply(book, change) if recovering: - self.status("resynced", f"book {watched.market_id} {side}", key=watched.market_id) + self._record_resync_and_emit_status(f"book {watched.market_id} {side}", key=watched.market_id) levels_bids, levels_asks = book.levels() self.emit(BookEvent( venue=VENUE, market_id=watched.market_id, side=side, kind="snapshot", # type: ignore[arg-type] @@ -340,11 +340,19 @@ def _apply(book: LocalBook, change: dict[str, Any]) -> tuple[str, Decimal, Decim # -- reading -------------------------------------------------------------- - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: + """Process a complete frame; this adapter owns native/Python selection.""" + events = self._try_process_frame_with_rust(raw) + # An empty list means handled with no events; only None requests fallback. + if events is not None: + return events + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _try_process_frame_with_rust(self, raw: str) -> list[Event] | None: """A depth change, decoded and applied by the Rust core on the same book. A change held back while a snapshot is fetched stays a dict for the replay, so that, everything else, and everything when the - core is not built, goes to `handle`.""" + core is not built, goes to `_handle_decoded_message`.""" if _native.core is None: return None change = _native.core.opinion_depth(raw) @@ -367,11 +375,11 @@ def handle_raw(self, raw: str) -> list[Event] | None: bids=bids, asks=asks, best_bid=best_bid, best_ask=best_ask, )] - def handle(self, message: Any) -> list[Event]: + def _handle_decoded_message(self, message: Any) -> list[Event]: if isinstance(message, list): events: list[Event] = [] for item in message: - events.extend(self.handle(item)) + events.extend(self._handle_decoded_message(item)) return events if not isinstance(message, dict): return [] @@ -464,11 +472,15 @@ async def watch_orders(self, market_ids: list[str]) -> None: watch_my_trades = watch_orders - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if isinstance(message, list): events: list[Event] = [] for item in message: - events.extend(self.handle(item)) + events.extend(self._handle_decoded_message(item)) return events if not isinstance(message, dict): return [] diff --git a/synpath/ws/polymarket.py b/synpath/ws/polymarket.py index 88cf12b..7148355 100644 --- a/synpath/ws/polymarket.py +++ b/synpath/ws/polymarket.py @@ -30,6 +30,7 @@ from ..trading.polymarket import GAMMA_URL, MarketTokens, fills_of, order_of from ..trading.types import Account, OrderStatus, Side from .base import ( + decode_json_or_text, ONE, BookEvent, BookLevel, D, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, QuoteEvent, Stream, TradeEvent, VenueEvent, maybe_D, @@ -60,12 +61,12 @@ def __init__(self, *, url: str = MARKET_URL, gamma_url: str = GAMMA_URL, **kwarg super().__init__(url, **kwargs) self.tokens: set[str] = set() self.books: dict[str, LocalBook] = {} + # Token id -> condition id, learned from the stream. self.markets: dict[str, str] = {} - """Token id -> condition id, learned from the stream.""" + # Which token is which side of which market, from the Gamma catalog. self.catalog = TokenCatalog(gamma_url) - """Which token is which side of which market, from the Gamma catalog.""" + # Token -> (timestamp, venue best bid, venue best ask) not yet checked. self._unverified: dict[str, tuple[int | None, Any, Any]] = {} - """Token -> (timestamp, venue best bid, venue best ask) not yet checked.""" async def watch_order_book(self, market_ids: list[str]) -> None: """Books, trades, top of book and lifecycle for these markets, both @@ -128,11 +129,19 @@ def _where(self, token: str, condition: str) -> tuple[str, str]: # -- reading -------------------------------------------------------------- - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: + """Process a complete frame; this adapter owns native/Python selection.""" + events = self._try_process_frame_with_rust(raw) + # An empty list means handled with no events; only None requests fallback. + if events is not None: + return events + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _try_process_frame_with_rust(self, raw: str) -> list[Event] | None: """Book snapshots and price changes, parsed and applied by the Rust core on these same books, with the same top-of-book checks. Frames holding anything else, and everything when the core is not built, - go to `handle`.""" + go to `_handle_decoded_message`.""" if _native.core is None: return None done = _native.core.polymarket_market(raw, self.books, self._unverified, self.markets) @@ -149,17 +158,17 @@ def handle_raw(self, raw: str) -> list[Event] | None: )) elif item[0] == "gap": _, token, local_bid, local_ask, venue_bid, venue_ask = item - self.status("gap", f"book {token}: local top {local_bid}/{local_ask}, venue {venue_bid}/{venue_ask}", key=token) + self._record_gap_and_emit_status(f"book {token}: local top {local_bid}/{local_ask}, venue {venue_bid}/{venue_ask}", key=token) self._later(self._resubscribe(token)) elif item[0] == "resynced": - self.status("resynced", f"book {item[1]}", key=item[1]) + self._record_resync_and_emit_status(f"book {item[1]}", key=item[1]) return events - def handle(self, message: Any) -> list[Event]: + def _handle_decoded_message(self, message: Any) -> list[Event]: if isinstance(message, list): events: list[Event] = [] for item in message: - events.extend(self.handle(item)) + events.extend(self._handle_decoded_message(item)) return events if not isinstance(message, dict): return [] # PONG @@ -217,7 +226,7 @@ def _on_book(self, message: dict[str, Any]) -> list[Event]: book.timestamp = _ms(message.get("timestamp")) self._unverified.pop(token, None) if recovering: - self.status("resynced", f"book {token}", key=token) + self._record_resync_and_emit_status(f"book {token}", key=token) bids, asks = book.levels() market_id, side = self._where(token, market) return [BookEvent( @@ -276,8 +285,7 @@ def _verify(self, token: str) -> bool: _, best_bid, best_ask = pending if _agrees(book.best_bid, best_bid) and _agrees(book.best_ask, best_ask): return True - self.status( - "gap", f"book {token}: local top {book.best_bid}/{book.best_ask}, venue {best_bid}/{best_ask}", key=token, + self._record_gap_and_emit_status(f"book {token}: local top {book.best_bid}/{book.best_ask}, venue {best_bid}/{best_ask}", key=token, ) book.invalidate() self._later(self._resubscribe(token)) @@ -357,11 +365,15 @@ async def on_connect(self) -> None: frame["markets"] = sorted(self.markets) await self.send(frame) - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if isinstance(message, list): events: list[Event] = [] for item in message: - events.extend(self.handle(item)) + events.extend(self._handle_decoded_message(item)) return events if not isinstance(message, dict): return [] diff --git a/synpath/ws/polymarket_us.py b/synpath/ws/polymarket_us.py index ea9b829..98bef8a 100644 --- a/synpath/ws/polymarket_us.py +++ b/synpath/ws/polymarket_us.py @@ -35,6 +35,7 @@ ) from ..trading.types import Account, Fill, Liquidity, Side from .base import ( + decode_json_or_text, BookEvent, D, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream, TradeEvent, BalanceEvent, maybe_D, ) @@ -96,7 +97,7 @@ def _common(self, message: dict[str, Any]) -> list[Event] | None: if "heartbeat" in message: return [] if "error" in message: - self.status("error", str(message.get("error")), key=message.get("requestId") or message.get("request_id")) + self._record_error_and_emit_status(str(message.get("error")), key=message.get("requestId") or message.get("request_id")) return [] return None @@ -122,8 +123,8 @@ def __init__(self, credentials: PolymarketUSCredentials, **kwargs: Any): self.states: dict[str, str] = {} async def watch_order_book(self, market_ids: list[str], *, debounced: bool = False) -> None: + # Full visible book and market state for these markets. slugs = _slugs(market_ids) - """Full visible book and market state for these markets.""" for slug in slugs: self.books.setdefault(slug, LocalBook()) await self._subscribe("SUBSCRIPTION_TYPE_MARKET_DATA", slugs) @@ -145,10 +146,18 @@ def book(self, market_id: str, side: str = "yes") -> LocalBook | None: book = self.books.get(ids.native(VENUE, market_id)) return None if book is None else book if side != "no" else book.mirrored() - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: + """Process a complete frame; this adapter owns native/Python selection.""" + events = self._try_process_frame_with_rust(raw) + # An empty list means handled with no events; only None requests fallback. + if events is not None: + return events + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _try_process_frame_with_rust(self, raw: str) -> list[Event] | None: """Full market data, parsed and applied to the book by the Rust core. Everything else, and everything when the core is not built, goes to - `handle`.""" + `_handle_decoded_message`.""" if _native.core is None: return None done = _native.core.polymarket_us_market_data(raw, self.books) @@ -159,7 +168,7 @@ def handle_raw(self, raw: str) -> list[Event] | None: self.books[slug].timestamp = stamp return self._market_events(slug, bids, asks, best_bid, best_ask, stamp, native, stats) - def handle(self, message: Any) -> list[Event]: + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] common = self._common(message) @@ -233,9 +242,9 @@ class PolymarketUSPrivateStream(_SignedStream): } async def watch_orders(self, market_ids: list[str] | None = None) -> None: + # Open orders as a snapshot, then every execution: acceptance, fills, + # cancels, replaces, rejects. Fills arrive as `FillEvent`s too. slugs = _slugs(market_ids) - """Open orders as a snapshot, then every execution: acceptance, fills, - cancels, replaces, rejects. Fills arrive as `FillEvent`s too.""" await self._subscribe("SUBSCRIPTION_TYPE_ORDER", slugs) watch_my_trades = watch_orders @@ -246,7 +255,11 @@ async def watch_positions(self, market_ids: list[str] | None = None) -> None: async def watch_balance(self) -> None: await self._subscribe("SUBSCRIPTION_TYPE_ACCOUNT_BALANCE", None) - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict): return [] common = self._common(message) diff --git a/synpath/ws/polymarket_us_exchange.py b/synpath/ws/polymarket_us_exchange.py index b9402cf..51c2356 100644 --- a/synpath/ws/polymarket_us_exchange.py +++ b/synpath/ws/polymarket_us_exchange.py @@ -98,13 +98,13 @@ "INSTRUMENT_STATE_TERMINATED": "settled", } +# Every other state (new, in flight, pending, acknowledged, retrying, and any +# state added later) is a trade still on its way: `matched`. TRADE_SETTLEMENT = { "TRADE_STATE_CLEARED": SettlementState.CONFIRMED, "TRADE_STATE_BUSTED": SettlementState.FAILED, "TRADE_STATE_REJECTED": SettlementState.FAILED, } -"""Every other state (new, in flight, pending, acknowledged, retrying, and any -state added later) is a trade still on its way: `matched`.""" def load_protos(path: Any = None, **kwargs: Any) -> ProtoBundle: @@ -232,8 +232,8 @@ def side(rows: Any) -> list[tuple[Decimal, Decimal]]: class _ExchangeStream(GrpcStream): venue = VENUE + # Sends `x-participant-id`; reference and market data need none. account_scoped = True - """Sends `x-participant-id`; reference and market data need none.""" def __init__( self, @@ -288,7 +288,7 @@ async def prepare(self, message: dict[str, Any]) -> None: try: await self.trading.load_instruments(missing, strict=False) except ExchangeError as exc: - self.status("error", f"reference data for {missing[:3]} failed: {exc}") + self._record_error_and_emit_status(f"reference data for {missing[:3]} failed: {exc}") def _each(self, items: Any, read: Callable[[dict[str, Any]], list[Event]]) -> list[Event]: """Read items one at a time, so one unreadable item costs only itself.""" @@ -297,7 +297,7 @@ def _each(self, items: Any, read: Callable[[dict[str, Any]], list[Event]]) -> li try: events.extend(read(item)) except Exception as exc: - self.status("error", f"unreadable item: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"unreadable item: {type(exc).__name__}: {exc}") return events @@ -322,10 +322,10 @@ def __init__( self.firms = list(firms or []) if isinstance(resume_token, bytes): resume_token = base64.b64encode(resume_token).decode() + # The last token, base64 as the venue's JSON mapping writes bytes. self.resume_token: str | None = resume_token or None - """The last token, base64 as the venue's JSON mapping writes bytes.""" + # Where to resume from without a token, in milliseconds. self.resume_time: int | None = resume_time - """Where to resume from without a token, in milliseconds.""" self.last_event_time: int | None = None request_fields: tuple[str, ...] = ("symbols", "firms") @@ -352,7 +352,7 @@ def classify(self, code: str | None) -> Outcome: since = self.last_event_time if self.last_event_time is not None else self.resume_time self.resume_token = None self.resume_time = since - self.status("error", f"the venue refused the resume token ({code}); resuming from {rfc3339(since) if since else 'now'}") + self._record_error_and_emit_status(f"the venue refused the resume token ({code}); resuming from {rfc3339(since) if since else 'now'}") return "retry" return super().classify(code) @@ -499,8 +499,8 @@ def __init__(self, source: Any, *, symbols: list[str] | None = None, accounts: l self.session_id: str | None = None async def watch_orders(self, market_ids: list[str] | None = None, accounts: list[str] | None = None) -> None: + # Narrow (or widen, with `None`) what the stream carries; empty means everything. symbols = None if market_ids is None else [ids.native(VENUE, m) for m in market_ids] - """Narrow (or widen, with `None`) what the stream carries; empty means everything.""" symbols, accounts = list(symbols or []), list(accounts or []) if (symbols, accounts) != (self.symbols, self.accounts): self.symbols, self.accounts = symbols, accounts @@ -644,7 +644,7 @@ async def before_call(self, request: dict[str, Any]) -> dict[str, Any] | None: await self.trading.load_instruments(missing, strict=False) unknown = [s for s in request["symbols"] if self.trading.cached_scale(s) is None] if unknown: - self.status("error", f"no reference data for {unknown[:5]}; not subscribed") + self._record_error_and_emit_status(f"no reference data for {unknown[:5]}; not subscribed") self.symbols = [s for s in self.symbols if s not in unknown] request = {**request, "symbols": [s for s in request["symbols"] if s not in unknown]} return request if request["symbols"] else None diff --git a/synpath/ws/predict_fun.py b/synpath/ws/predict_fun.py index f4b02b9..759bf53 100644 --- a/synpath/ws/predict_fun.py +++ b/synpath/ws/predict_fun.py @@ -34,12 +34,13 @@ from ..trading.predict_fun import fill_of_event, order_of_event from ..trading.types import Account from .base import ( + decode_json_or_text, BookEvent, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, QuoteEvent, Stream, VenueEvent, maybe_D, ) VENUE = "predict_fun" +# Mainnet. The venue documents no test-network WebSocket; pass `url=` for one. WS_URL = "wss://ws.predict.fun/ws" -"""Mainnet. The venue documents no test-network WebSocket; pass `url=` for one.""" MARKET_STATE = { "REGISTERED": "open", "OPEN": "open", "UNPAUSED": "open", "PAUSED": "paused", @@ -70,17 +71,17 @@ def __init__(self, *, api_key: str | None = None, url: str | None = None, **kwar ) super().__init__(url or WS_URL, **kwargs) self._key = key + # Topic -> the request id it was last subscribed with; sent again on every connect. self.topics: dict[str, int] = {} - """Topic -> the request id it was last subscribed with; sent again on every connect.""" self._next_id = 1 def headers(self) -> dict[str, str]: return {"x-api-key": self._key} if self._key else {} - def status(self, state: Any, detail: str = "", **kwargs: Any) -> None: + def _emit_status_event(self, state: Any, detail: str = "", **kwargs: Any) -> None: # A wallet JWT travels in a topic string, so it is kept out of anything reported. jwt = getattr(self, "_jwt", None) - super().status(state, detail.replace(jwt, "***") if jwt and detail else detail, **kwargs) + super()._emit_status_event(state, detail.replace(jwt, "***") if jwt and detail else detail, **kwargs) async def _subscribe(self, topic: str) -> None: if topic in self.topics: @@ -116,7 +117,7 @@ def _envelope(self, message: Any) -> tuple[str, Any] | None: else: topic = next((t for t, rid in self.topics.items() if rid == message.get("requestId")), None) if topic: - self.status("subscribed", topic.split("/")[0], key=topic.split("/")[-1] if "Wallet" not in topic else None) + self._emit_status_event("subscribed", topic.split("/")[0], key=topic.split("/")[-1] if "Wallet" not in topic else None) return None topic = str(message.get("topic") or "") if topic == "heartbeat": @@ -125,7 +126,7 @@ def _envelope(self, message: Any) -> tuple[str, Any] | None: return topic, message.get("data") def _refused(self, topic: str, code: str, detail: str) -> None: - self.status("error", f"venue refused {topic.split('/')[0] or 'a request'}: {code} {detail}".strip()) + self._record_error_and_emit_status(f"venue refused {topic.split('/')[0] or 'a request'}: {code} {detail}".strip()) def _later(self, coroutine: Any) -> None: import asyncio @@ -156,11 +157,11 @@ class PredictFunMarketStream(_PredictFunStream): def __init__(self, **kwargs: Any): super().__init__(**kwargs) + # Native market id -> its YES book. self.books: dict[str, LocalBook] = {} - """Native market id -> its YES book.""" self.tickers: set[str] = set() + # Native market id -> the id of the last settled order seen. self._last: dict[str, str] = {} - """Native market id -> the id of the last settled order seen.""" async def watch_order_book(self, market_ids: list[str]) -> None: """Each market's whole book, on every change.""" @@ -201,7 +202,11 @@ def on_disconnect(self) -> None: for book in self.books.values(): book.invalidate() - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: envelope = self._envelope(message) if envelope is None: return [] @@ -226,7 +231,7 @@ def _on_book(self, native: str, data: dict[str, Any]) -> list[Event]: book.replace(_levels(data.get("bids")), _levels(data.get("asks"))) book.timestamp = int(data["updateTimestampMs"]) if data.get("updateTimestampMs") else None if recovering: - self.status("resynced", f"book {market_id}", key=market_id) + self._record_resync_and_emit_status(f"book {market_id}", key=market_id) bids, asks = book.levels() events.append(BookEvent( venue=VENUE, market_id=market_id, side="yes", kind="snapshot", bids=bids, asks=asks, @@ -305,7 +310,7 @@ def _refused(self, topic: str, code: str, detail: str) -> None: self._later(self._renew()) return if code == "invalid_credentials": - self.status("failed", "the wallet JWT was refused (expired or revoked); pass a fresh one") + self._emit_status_event("failed", "the wallet JWT was refused (expired or revoked); pass a fresh one") self._later(self.close()) return super()._refused(topic, code, detail) @@ -313,7 +318,11 @@ def _refused(self, topic: str, code: str, detail: str) -> None: async def _renew(self) -> None: await self._subscribe(f"predictWalletEvents/{await self._token(fresh=True)}") - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: envelope = self._envelope(message) if envelope is None: return [] diff --git a/synpath/ws/smarkets.py b/synpath/ws/smarkets.py index 310e4f6..3ff82fa 100644 --- a/synpath/ws/smarkets.py +++ b/synpath/ws/smarkets.py @@ -42,7 +42,7 @@ from .. import ids from ..base import Capability, RateLimiter -from .base import BookEvent, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms +from .base import BookEvent, Event, LocalBook, MarketStatusEvent, QuoteEvent, Stream, now_ms, decode_json_or_text from .betfair import changed # shared: the levels that differ between two book sides @@ -51,11 +51,11 @@ VENUE = "smarkets" SCALE = Decimal(10_000) +# Seconds between polls: 12 quotes requests a minute, inside the anonymous 20. DEFAULT_INTERVAL = 5.0 -"""Seconds between polls: 12 quotes requests a minute, inside the anonymous 20.""" +# Smarkets markets one stream polls; five quotes requests a poll at most. MAX_MARKETS = 1000 -"""Smarkets markets one stream polls; five quotes requests a poll at most.""" def yes_levels(rows: Any) -> list[tuple[Decimal, Decimal]]: @@ -155,17 +155,17 @@ def __init__( self._own_client = client is None self.quotes_limiter = QUOTES_LIMITER if quotes_limiter == "shared" else quotes_limiter self.limiter = LIMITER if limiter == "shared" else limiter + # Contract's Synpath id -> its YES book. self.books: dict[str, LocalBook] = {} - """Contract's Synpath id -> its YES book.""" self.tickers: set[str] = set() + # Smarkets markets watched as a whole: every contract's book is kept. self.whole_markets: set[str] = set() - """Smarkets markets watched as a whole: every contract's book is kept.""" + # Smarkets market ids whose status changes are passed on; `None` while not watching. self.statuses: set[str] | None = None - """Smarkets market ids whose status changes are passed on; `None` while not watching.""" + # Smarkets market ids polled. self.wanted: set[str] = set() - """Smarkets market ids polled.""" + # Contract id -> Smarkets market id, for markets watched whole. self.contract_market: dict[str, str] = {} - """Contract id -> Smarkets market id, for markets watched whole.""" self.polls = 0 self._top: dict[str, tuple[Any, ...]] = {} self._last_state: dict[str, tuple[str, str | None]] = {} @@ -272,7 +272,7 @@ def _want(self, markets: list[str]) -> set[str]: continue self.wanted.add(market) if refused: - self.status("error", f"more than {MAX_MARKETS} markets on one stream; not polled: {', '.join(sorted(refused))}") + self._record_error_and_emit_status(f"more than {MAX_MARKETS} markets on one stream; not polled: {', '.join(sorted(refused))}") return refused async def watch_order_book(self, market_ids: list[str]) -> None: @@ -331,7 +331,11 @@ def book(self, market_id: str, side: str = "yes") -> LocalBook | None: # -- messages ------------------------------------------------------------- - def handle(self, message: Any) -> list[Event]: + def handle_raw(self, raw: str) -> list[Event]: + """Decode this venue's JSON/text frame and interpret its message.""" + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message: Any) -> list[Event]: if not isinstance(message, dict) or message.get("op") != "poll": return [] events: list[Event] = [] @@ -373,7 +377,7 @@ def _book_events(self, synpath_id: str, contract_id: str, quote: dict[str, Any], best_bid=book.best_bid, best_ask=book.best_ask, info=info, )) if recovering: - self.status("resynced", f"book {synpath_id}", key=synpath_id) + self._record_resync_and_emit_status(f"book {synpath_id}", key=synpath_id) else: bid_changes, ask_changes = changed(old_bids, new_bids), changed(old_asks, new_asks) if bid_changes or ask_changes: diff --git a/synpath/ws/sx_bet.py b/synpath/ws/sx_bet.py index 16b7ee9..ec06df0 100644 --- a/synpath/ws/sx_bet.py +++ b/synpath/ws/sx_bet.py @@ -23,6 +23,9 @@ whole venue's tape of taker bets and `markets:global` every market change; the stream filters both down to the markets asked for (or passes everything when asked for none). + +**Parlay requests** arrive on `parlay_markets:global` (`SxBetParlayStream`): +each is a parlay market a bettor just asked for, open to makers for 60 s. """ from __future__ import annotations @@ -46,8 +49,8 @@ ODDS_SCALE = Decimal(10) ** 20 USDC = Decimal(1_000_000) +# Subscriptions one connection takes. MAX_CHANNELS = 512 -"""Subscriptions one connection takes.""" def hash_of(market_id: str) -> str: @@ -124,9 +127,9 @@ def __init__( super().__init__(url or (TESTNET_WS_URL if testnet else WS_URL), **kwargs) self.api_url = api_url or (TESTNET_API_URL if testnet else API_URL) self._api_key = api_key + # A token string, or a callable (sync or async) returning one; for tests + # and for callers who fetch tokens themselves. self._token_source = token - """A token string, or a callable (sync or async) returning one; for tests - and for callers who fetch tokens themselves.""" self.ready = False self.channels: set[str] = set() self._live: set[str] = set() @@ -178,7 +181,7 @@ def on_disconnect(self) -> None: self._renewer.cancel() self._renewer = None - def handle_raw(self, raw: str) -> list[Event] | None: + def handle_raw(self, raw: str) -> list[Event]: events: list[Event] = [] for line in raw.split("\n"): if not line.strip(): @@ -200,14 +203,14 @@ def _message(self, message: dict[str, Any]) -> list[Event]: kind, channel = self._pending.pop(int(message["id"]), ("", "")) if message.get("error"): error = message["error"] - self.status("error", f"venue refused {kind} {channel}: {error.get('code')} {error.get('message')}".strip()) + self._record_error_and_emit_status(f"venue refused {kind} {channel}: {error.get('code')} {error.get('message')}".strip()) if kind == "subscribe": self._live.discard(channel) return [] if kind == "connect": self._on_connected(message.get("connect") or {}) elif kind == "subscribe": - self.status("subscribed", channel, key=channel) + self._emit_status_event("subscribed", channel, key=channel) return self.on_subscribed(channel) return [] push = message.get("push") @@ -217,7 +220,7 @@ def _message(self, message: dict[str, Any]) -> list[Event]: if isinstance(publication, dict) and channel: return self.on_publication(channel, publication.get("data")) if push.get("disconnect"): - self.status("error", f"venue disconnected the stream: {push['disconnect']}") + self._record_error_and_emit_status(f"venue disconnected the stream: {push['disconnect']}") return [] def _on_connected(self, reply: dict[str, Any]) -> None: @@ -234,7 +237,7 @@ async def _renew(self, ttl: int) -> None: try: token = await self.fetch_token() except Exception as exc: # pragma: no cover - the venue's API down - self.status("error", f"could not renew the realtime token: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"could not renew the realtime token: {type(exc).__name__}: {exc}") continue await self.send(self._command("refresh", {"token": token})) @@ -244,7 +247,7 @@ async def resubscribe(self) -> None: return for channel in sorted(self.channels - self._live): if len(self._live) >= MAX_CHANNELS: - self.status("error", f"more than {MAX_CHANNELS} channels on one connection; {channel} not subscribed") + self._record_error_and_emit_status(f"more than {MAX_CHANNELS} channels on one connection; {channel} not subscribed") return self._live.add(channel) await self.send(self._command("subscribe", {"channel": channel}, channel=channel)) @@ -297,13 +300,13 @@ class SxBetMarketStream(_CentrifugoStream): def __init__(self, *, catalog: Any = None, **kwargs: Any): super().__init__(**kwargs) + # An `SxBet` for seeding books from REST; made when first needed. self.catalog = catalog - """An `SxBet` for seeding books from REST; made when first needed.""" + # Hash -> its YES book. self.books: dict[str, LocalBook] = {} - """Hash -> its YES book.""" self.tickers: set[str] = set() + # Hashes whose bets are passed on; `None` while not watching, empty for all. self.trades: set[str] | None = None - """Hashes whose bets are passed on; `None` while not watching, empty for all.""" self.statuses: set[str] | None = None self._versions: dict[str, str] = {} @@ -379,7 +382,7 @@ async def _seed(self, market_hash: str) -> None: catalog._get, "/orderbook-v3/snapshot", {"marketHash": market_hash}, ) except Exception as exc: # pragma: no cover - the venue's API down - self.status("error", f"could not seed book {market_hash}: {type(exc).__name__}: {exc}") + self._record_error_and_emit_status(f"could not seed book {market_hash}: {type(exc).__name__}: {exc}") return if isinstance(payload, dict): for event in self._on_book({**payload, "marketHash": market_hash}): @@ -432,7 +435,7 @@ def _on_book(self, data: dict[str, Any]) -> list[Event]: book.replace(bids, asks) book.timestamp = now_ms() if recovering: - self.status("resynced", f"book {market_id}", key=market_id) + self._record_resync_and_emit_status(f"book {market_id}", key=market_id) levels_bids, levels_asks = book.levels() events.append(BookEvent( venue=VENUE, market_id=market_id, side="yes", kind="snapshot", bids=levels_bids, asks=levels_asks, @@ -497,3 +500,53 @@ def on_publication(self, channel: str, data: Any) -> list[Event]: fill = fill_of_row(data["fill"], account=self.account) return [FillEvent(venue=VENUE, fill=fill)] if fill else [] return [] + + +PARLAY_CHANNEL = "parlay_markets:global" + + +class SxBetParlayStream(_CentrifugoStream): + """Parlay requests as they are made: the venue's `parlay_markets:global` + channel. Each request is a new parlay market a bettor asked for, open to + makers' orders for 60 seconds; it arrives as + `VenueEvent(name="parlay_request")` whose payload holds the venue's row + (`row`) and the request as an incoming `Rfq` (`rfq`, `mine=False`, with + the legs as a `Combo`), or `None` where the row cannot be read as one. + Needs an SX Bet API key, as every channel on this venue does. + + The venue replays nothing: a request published while the stream was down + is gone, and there is no REST listing of open requests to catch up from. + + ```python + async with SxBetParlayStream(api_key="...") as stream: + await stream.watch_parlays() + async for event in stream: + ... + ``` + """ + + name = "parlays" + + async def watch_parlays(self) -> None: + """Every parlay request, venue-wide.""" + await self.add_channels([PARLAY_CHANNEL]) + + def on_publication(self, channel: str, data: Any) -> list[Event]: + if channel != PARLAY_CHANNEL: + return [] + from ..trading.sx_bet import rfq_of_parlay + from .base import VenueEvent + + events: list[Event] = [] + for row in data if isinstance(data, list) else [data]: + if not isinstance(row, dict) or not row.get("marketHash"): + continue + received = now_ms() + try: + rfq = rfq_of_parlay(row, received_at=received) + except ValueError as exc: + self._record_error_and_emit_status(f"unreadable parlay request {row.get('marketHash')}: {exc}") + rfq = None + events.append(VenueEvent(venue=VENUE, name="parlay_request", payload={"row": row, "rfq": rfq}, + timestamp=received)) + return events diff --git a/tests/samples/kalshi_combo_board_live.json b/tests/samples/kalshi_combo_board_live.json new file mode 100644 index 0000000..03167c6 --- /dev/null +++ b/tests/samples/kalshi_combo_board_live.json @@ -0,0 +1,1744 @@ +{ + "source": "GET api.elections.kalshi.com/trade-api/v2/events?tickers=...&with_nested_markets=true, the events of the open collection KXMVESPORTSMULTIGAMEEXTENDED-R; read 2026-10-22, trimmed", + "events": [ + { + "event_ticker": "KXNFLTOTAL-26OCT22NECHI", + "series_ticker": "KXNFLTOTAL", + "title": "New England vs Chicago: Total Points", + "sub_title": "NE vs CHI (Oct 22)", + "category": "Sports", + "mutually_exclusive": false, + "product_metadata": { + "competition": "Pro Football", + "competition_scope": "Point Total" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNFLTOTAL-26OCT22NECHI-51", + "event_ticker": "KXNFLTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "Full Game: over 50.5 points scored?", + "yes_sub_title": "Over 50.5 points", + "no_sub_title": "Over 50.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 50.5, + "yes_bid_dollars": "0.5100", + "yes_ask_dollars": "0.5200", + "no_bid_dollars": "0.4800", + "no_ask_dollars": "0.4900", + "last_price_dollars": "0.5200", + "volume_fp": "289228.83", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTOTAL-26OCT22NECHI-52", + "event_ticker": "KXNFLTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "Full Game: over 51.5 points scored?", + "yes_sub_title": "Over 51.5 points", + "no_sub_title": "Over 51.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 51.5, + "yes_bid_dollars": "0.4600", + "yes_ask_dollars": "0.4700", + "no_bid_dollars": "0.5300", + "no_ask_dollars": "0.5400", + "last_price_dollars": "0.4600", + "volume_fp": "26764.17", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTOTAL-26OCT22NECHI-50", + "event_ticker": "KXNFLTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "Full Game: over 49.5 points scored?", + "yes_sub_title": "Over 49.5 points", + "no_sub_title": "Over 49.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 49.5, + "yes_bid_dollars": "0.5400", + "yes_ask_dollars": "0.5500", + "no_bid_dollars": "0.4500", + "no_ask_dollars": "0.4600", + "last_price_dollars": "0.5500", + "volume_fp": "99670.31", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTOTAL-26OCT22NECHI-49", + "event_ticker": "KXNFLTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "Full Game: over 48.5 points scored?", + "yes_sub_title": "Over 48.5 points", + "no_sub_title": "Over 48.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 48.5, + "yes_bid_dollars": "0.5800", + "yes_ask_dollars": "0.5900", + "no_bid_dollars": "0.4100", + "no_ask_dollars": "0.4200", + "last_price_dollars": "0.5900", + "volume_fp": "2006.30", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTOTAL-26OCT22NECHI-48", + "event_ticker": "KXNFLTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "Full Game: over 47.5 points scored?", + "yes_sub_title": "Over 47.5 points", + "no_sub_title": "Over 47.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 47.5, + "yes_bid_dollars": "0.6000", + "yes_ask_dollars": "0.6100", + "no_bid_dollars": "0.3900", + "no_ask_dollars": "0.4000", + "last_price_dollars": "0.6100", + "volume_fp": "1992.86", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTOTAL-26OCT22NECHI-55", + "event_ticker": "KXNFLTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "Full Game: over 54.5 points scored?", + "yes_sub_title": "Over 54.5 points", + "no_sub_title": "Over 54.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 54.5, + "yes_bid_dollars": "0.3900", + "yes_ask_dollars": "0.4000", + "no_bid_dollars": "0.6000", + "no_ask_dollars": "0.6100", + "last_price_dollars": "0.4000", + "volume_fp": "9581.65", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNFLTEAMTOTAL-26OCT22NECHI", + "series_ticker": "KXNFLTEAMTOTAL", + "title": "New England vs Chicago: Team Total", + "sub_title": "NE vs CHI (Oct 22)", + "category": "Sports", + "mutually_exclusive": false, + "product_metadata": { + "competition": "Pro Football", + "competition_scope": "Team Total" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNFLTEAMTOTAL-26OCT22NECHI-NE24", + "event_ticker": "KXNFLTEAMTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "New England over 23.5 points scored", + "yes_sub_title": "New England over 23.5 points", + "no_sub_title": "New England over 23.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 23.5, + "custom_strike": { + "football_team": "d1d64188-5cd5-4feb-97ff-8542c9dbeb43" + }, + "yes_bid_dollars": "0.5200", + "yes_ask_dollars": "0.5300", + "no_bid_dollars": "0.4700", + "no_ask_dollars": "0.4800", + "last_price_dollars": "0.5200", + "volume_fp": "1642.74", + "open_time": "2026-10-19T07:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTEAMTOTAL-26OCT22NECHI-NE25", + "event_ticker": "KXNFLTEAMTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "New England over 24.5 points scored", + "yes_sub_title": "New England over 24.5 points", + "no_sub_title": "New England over 24.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 24.5, + "custom_strike": { + "football_team": "d1d64188-5cd5-4feb-97ff-8542c9dbeb43" + }, + "yes_bid_dollars": "0.4700", + "yes_ask_dollars": "0.4800", + "no_bid_dollars": "0.5200", + "no_ask_dollars": "0.5300", + "last_price_dollars": "0.4800", + "volume_fp": "1451.91", + "open_time": "2026-10-19T07:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTEAMTOTAL-26OCT22NECHI-CHI27", + "event_ticker": "KXNFLTEAMTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "Chicago over 26.5 points scored", + "yes_sub_title": "Chicago over 26.5 points", + "no_sub_title": "Chicago over 26.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 26.5, + "custom_strike": { + "football_team": "9a77500a-1945-495e-b6f5-5c35bedaa8b9" + }, + "yes_bid_dollars": "0.5300", + "yes_ask_dollars": "0.5400", + "no_bid_dollars": "0.4600", + "no_ask_dollars": "0.4700", + "last_price_dollars": "0.5400", + "volume_fp": "1223.40", + "open_time": "2026-10-19T07:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTEAMTOTAL-26OCT22NECHI-CHI29", + "event_ticker": "KXNFLTEAMTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "Chicago over 28.5 points scored", + "yes_sub_title": "Chicago over 28.5 points", + "no_sub_title": "Chicago over 28.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 28.5, + "custom_strike": { + "football_team": "9a77500a-1945-495e-b6f5-5c35bedaa8b9" + }, + "yes_bid_dollars": "0.4100", + "yes_ask_dollars": "0.4200", + "no_bid_dollars": "0.5800", + "no_ask_dollars": "0.5900", + "last_price_dollars": "0.4200", + "volume_fp": "943.20", + "open_time": "2026-10-19T07:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTEAMTOTAL-26OCT22NECHI-CHI25", + "event_ticker": "KXNFLTEAMTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "Chicago over 24.5 points scored", + "yes_sub_title": "Chicago over 24.5 points", + "no_sub_title": "Chicago over 24.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 24.5, + "custom_strike": { + "football_team": "9a77500a-1945-495e-b6f5-5c35bedaa8b9" + }, + "yes_bid_dollars": "0.5800", + "yes_ask_dollars": "0.6000", + "no_bid_dollars": "0.4000", + "no_ask_dollars": "0.4200", + "last_price_dollars": "0.5900", + "volume_fp": "88.43", + "open_time": "2026-10-19T07:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLTEAMTOTAL-26OCT22NECHI-NE22", + "event_ticker": "KXNFLTEAMTOTAL-26OCT22NECHI", + "market_type": "binary", + "title": "New England over 21.5 points scored", + "yes_sub_title": "New England over 21.5 points", + "no_sub_title": "New England over 21.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 21.5, + "custom_strike": { + "football_team": "d1d64188-5cd5-4feb-97ff-8542c9dbeb43" + }, + "yes_bid_dollars": "0.6000", + "yes_ask_dollars": "0.6100", + "no_bid_dollars": "0.3900", + "no_ask_dollars": "0.4000", + "last_price_dollars": "0.6100", + "volume_fp": "22.00", + "open_time": "2026-10-19T07:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNFLSPREAD-26OCT22NECHI", + "series_ticker": "KXNFLSPREAD", + "title": "New England vs Chicago: Spread", + "sub_title": "NE vs CHI (Oct 22)", + "category": "Sports", + "mutually_exclusive": false, + "product_metadata": { + "competition": "Pro Football", + "competition_scope": "Spread" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNFLSPREAD-26OCT22NECHI-CHI3", + "event_ticker": "KXNFLSPREAD-26OCT22NECHI", + "market_type": "binary", + "title": "Chicago wins by over 2.5 points?", + "yes_sub_title": "Chicago wins by over 2.5 points", + "no_sub_title": "Chicago wins by over 2.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 2.5, + "custom_strike": { + "football_team": "9a77500a-1945-495e-b6f5-5c35bedaa8b9" + }, + "yes_bid_dollars": "0.5400", + "yes_ask_dollars": "0.5500", + "no_bid_dollars": "0.4500", + "no_ask_dollars": "0.4600", + "last_price_dollars": "0.5500", + "volume_fp": "256413.25", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLSPREAD-26OCT22NECHI-CHI4", + "event_ticker": "KXNFLSPREAD-26OCT22NECHI", + "market_type": "binary", + "title": "Chicago wins by over 3.5 points?", + "yes_sub_title": "Chicago wins by over 3.5 points", + "no_sub_title": "Chicago wins by over 3.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 3.5, + "custom_strike": { + "football_team": "9a77500a-1945-495e-b6f5-5c35bedaa8b9" + }, + "yes_bid_dollars": "0.4400", + "yes_ask_dollars": "0.4500", + "no_bid_dollars": "0.5500", + "no_ask_dollars": "0.5600", + "last_price_dollars": "0.4400", + "volume_fp": "173432.68", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLSPREAD-26OCT22NECHI-CHI2", + "event_ticker": "KXNFLSPREAD-26OCT22NECHI", + "market_type": "binary", + "title": "Chicago wins by over 1.5 points?", + "yes_sub_title": "Chicago wins by over 1.5 points", + "no_sub_title": "Chicago wins by over 1.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 1.5, + "custom_strike": { + "football_team": "9a77500a-1945-495e-b6f5-5c35bedaa8b9" + }, + "yes_bid_dollars": "0.5600", + "yes_ask_dollars": "0.5700", + "no_bid_dollars": "0.4300", + "no_ask_dollars": "0.4400", + "last_price_dollars": "0.5700", + "volume_fp": "5197.89", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLSPREAD-26OCT22NECHI-CHI5", + "event_ticker": "KXNFLSPREAD-26OCT22NECHI", + "market_type": "binary", + "title": "Chicago wins by over 4.5 points?", + "yes_sub_title": "Chicago wins by over 4.5 points", + "no_sub_title": "Chicago wins by over 4.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 4.5, + "custom_strike": { + "football_team": "9a77500a-1945-495e-b6f5-5c35bedaa8b9" + }, + "yes_bid_dollars": "0.4100", + "yes_ask_dollars": "0.4200", + "no_bid_dollars": "0.5800", + "no_ask_dollars": "0.5900", + "last_price_dollars": "0.4200", + "volume_fp": "11225.09", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLSPREAD-26OCT22NECHI-CHI6", + "event_ticker": "KXNFLSPREAD-26OCT22NECHI", + "market_type": "binary", + "title": "Chicago wins by over 5.5 points?", + "yes_sub_title": "Chicago wins by over 5.5 points", + "no_sub_title": "Chicago wins by over 5.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 5.5, + "custom_strike": { + "football_team": "9a77500a-1945-495e-b6f5-5c35bedaa8b9" + }, + "yes_bid_dollars": "0.3900", + "yes_ask_dollars": "0.4000", + "no_bid_dollars": "0.6000", + "no_ask_dollars": "0.6100", + "last_price_dollars": "0.3900", + "volume_fp": "4798.83", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLSPREAD-26OCT22NECHI-NE2", + "event_ticker": "KXNFLSPREAD-26OCT22NECHI", + "market_type": "binary", + "title": "New England wins by over 1.5 points?", + "yes_sub_title": "New England wins by over 1.5 points", + "no_sub_title": "New England wins by over 1.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 1.5, + "custom_strike": { + "football_team": "d1d64188-5cd5-4feb-97ff-8542c9dbeb43" + }, + "yes_bid_dollars": "0.3800", + "yes_ask_dollars": "0.3900", + "no_bid_dollars": "0.6100", + "no_ask_dollars": "0.6200", + "last_price_dollars": "0.3900", + "volume_fp": "4155.47", + "open_time": "2026-10-16T01:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNFLGAME-26OCT22NECHI", + "series_ticker": "KXNFLGAME", + "title": "New England vs Chicago", + "sub_title": "NE vs CHI (Oct 22)", + "category": "Sports", + "mutually_exclusive": true, + "product_metadata": { + "competition": "Pro Football", + "competition_scope": "Game" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNFLGAME-26OCT22NECHI-CHI", + "event_ticker": "KXNFLGAME-26OCT22NECHI", + "market_type": "binary", + "title": "Chicago wins", + "yes_sub_title": "Chicago", + "no_sub_title": "Chicago", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "football_team": "9a77500a-1945-495e-b6f5-5c35bedaa8b9" + }, + "yes_bid_dollars": "0.5900", + "yes_ask_dollars": "0.6000", + "no_bid_dollars": "0.4000", + "no_ask_dollars": "0.4100", + "last_price_dollars": "0.6000", + "volume_fp": "744121.33", + "open_time": "2026-10-13T04:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNFLGAME-26OCT22NECHI-NE", + "event_ticker": "KXNFLGAME-26OCT22NECHI", + "market_type": "binary", + "title": "New England wins", + "yes_sub_title": "New England", + "no_sub_title": "New England", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "football_team": "d1d64188-5cd5-4feb-97ff-8542c9dbeb43" + }, + "yes_bid_dollars": "0.4000", + "yes_ask_dollars": "0.4100", + "no_bid_dollars": "0.5900", + "no_ask_dollars": "0.6000", + "last_price_dollars": "0.4100", + "volume_fp": "1425927.05", + "open_time": "2026-10-13T04:05:00Z", + "close_time": "2026-10-25T00:15:00Z", + "expected_expiration_time": "2026-10-23T03:15:00Z", + "occurrence_datetime": "2026-10-23T03:15:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNBATOTAL-26OCT23NOPUTA", + "series_ticker": "KXNBATOTAL", + "title": "New Orleans vs Utah: Total Points", + "sub_title": "NOP vs UTA (Oct 23)", + "category": "Sports", + "mutually_exclusive": false, + "product_metadata": { + "competition": "Pro Basketball (M)", + "competition_scope": "Point Total" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNBATOTAL-26OCT23NOPUTA-241", + "event_ticker": "KXNBATOTAL-26OCT23NOPUTA", + "market_type": "binary", + "title": "Full Game: Over 240.5 points scored", + "yes_sub_title": "Over 240.5 points", + "no_sub_title": "Over 240.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 240.5, + "yes_bid_dollars": "0.5200", + "yes_ask_dollars": "0.5300", + "no_bid_dollars": "0.4700", + "no_ask_dollars": "0.4800", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBATOTAL-26OCT23NOPUTA-243", + "event_ticker": "KXNBATOTAL-26OCT23NOPUTA", + "market_type": "binary", + "title": "Full Game: Over 242.5 points scored", + "yes_sub_title": "Over 242.5 points", + "no_sub_title": "Over 242.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 242.5, + "yes_bid_dollars": "0.4700", + "yes_ask_dollars": "0.4800", + "no_bid_dollars": "0.5200", + "no_ask_dollars": "0.5300", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBATOTAL-26OCT23NOPUTA-245", + "event_ticker": "KXNBATOTAL-26OCT23NOPUTA", + "market_type": "binary", + "title": "Full Game: Over 244.5 points scored", + "yes_sub_title": "Over 244.5 points", + "no_sub_title": "Over 244.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 244.5, + "yes_bid_dollars": "0.4200", + "yes_ask_dollars": "0.4400", + "no_bid_dollars": "0.5600", + "no_ask_dollars": "0.5800", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBATOTAL-26OCT23NOPUTA-239", + "event_ticker": "KXNBATOTAL-26OCT23NOPUTA", + "market_type": "binary", + "title": "Full Game: Over 238.5 points scored", + "yes_sub_title": "Over 238.5 points", + "no_sub_title": "Over 238.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 238.5, + "yes_bid_dollars": "0.5600", + "yes_ask_dollars": "0.5800", + "no_bid_dollars": "0.4200", + "no_ask_dollars": "0.4400", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBATOTAL-26OCT23NOPUTA-247", + "event_ticker": "KXNBATOTAL-26OCT23NOPUTA", + "market_type": "binary", + "title": "Full Game: Over 246.5 points scored", + "yes_sub_title": "Over 246.5 points", + "no_sub_title": "Over 246.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 246.5, + "yes_bid_dollars": "0.3800", + "yes_ask_dollars": "0.4100", + "no_bid_dollars": "0.5900", + "no_ask_dollars": "0.6200", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBATOTAL-26OCT23NOPUTA-237", + "event_ticker": "KXNBATOTAL-26OCT23NOPUTA", + "market_type": "binary", + "title": "Full Game: Over 236.5 points scored", + "yes_sub_title": "Over 236.5 points", + "no_sub_title": "Over 236.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 236.5, + "yes_bid_dollars": "0.6000", + "yes_ask_dollars": "0.6400", + "no_bid_dollars": "0.3600", + "no_ask_dollars": "0.4000", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNBASPREAD-26OCT23NOPUTA", + "series_ticker": "KXNBASPREAD", + "title": "New Orleans vs Utah: Spread", + "sub_title": "NOP vs UTA (Oct 23)", + "category": "Sports", + "mutually_exclusive": false, + "product_metadata": { + "competition": "Pro Basketball (M)", + "competition_scope": "Spread" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNBASPREAD-26OCT23NOPUTA-UTA5", + "event_ticker": "KXNBASPREAD-26OCT23NOPUTA", + "market_type": "binary", + "title": "Utah wins by over 4.5 points", + "yes_sub_title": "Utah wins by over 4.5 points", + "no_sub_title": "Utah wins by over 4.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 4.5, + "custom_strike": { + "basketball_team": "6f60dfb1-fda1-423f-b76f-67e9d24b9644" + }, + "yes_bid_dollars": "0.4800", + "yes_ask_dollars": "0.5000", + "no_bid_dollars": "0.5000", + "no_ask_dollars": "0.5200", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBASPREAD-26OCT23NOPUTA-UTA4", + "event_ticker": "KXNBASPREAD-26OCT23NOPUTA", + "market_type": "binary", + "title": "Utah wins by over 3.5 points", + "yes_sub_title": "Utah wins by over 3.5 points", + "no_sub_title": "Utah wins by over 3.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 3.5, + "custom_strike": { + "basketball_team": "6f60dfb1-fda1-423f-b76f-67e9d24b9644" + }, + "yes_bid_dollars": "0.5200", + "yes_ask_dollars": "0.5300", + "no_bid_dollars": "0.4700", + "no_ask_dollars": "0.4800", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBASPREAD-26OCT23NOPUTA-UTA6", + "event_ticker": "KXNBASPREAD-26OCT23NOPUTA", + "market_type": "binary", + "title": "Utah wins by over 5.5 points", + "yes_sub_title": "Utah wins by over 5.5 points", + "no_sub_title": "Utah wins by over 5.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 5.5, + "custom_strike": { + "basketball_team": "6f60dfb1-fda1-423f-b76f-67e9d24b9644" + }, + "yes_bid_dollars": "0.4500", + "yes_ask_dollars": "0.4600", + "no_bid_dollars": "0.5400", + "no_ask_dollars": "0.5500", + "last_price_dollars": "0.4400", + "volume_fp": "2117.73", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBASPREAD-26OCT23NOPUTA-UTA3", + "event_ticker": "KXNBASPREAD-26OCT23NOPUTA", + "market_type": "binary", + "title": "Utah wins by over 2.5 points", + "yes_sub_title": "Utah wins by over 2.5 points", + "no_sub_title": "Utah wins by over 2.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 2.5, + "custom_strike": { + "basketball_team": "6f60dfb1-fda1-423f-b76f-67e9d24b9644" + }, + "yes_bid_dollars": "0.5600", + "yes_ask_dollars": "0.5700", + "no_bid_dollars": "0.4300", + "no_ask_dollars": "0.4400", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBASPREAD-26OCT23NOPUTA-UTA7", + "event_ticker": "KXNBASPREAD-26OCT23NOPUTA", + "market_type": "binary", + "title": "Utah wins by over 6.5 points", + "yes_sub_title": "Utah wins by over 6.5 points", + "no_sub_title": "Utah wins by over 6.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 6.5, + "custom_strike": { + "basketball_team": "6f60dfb1-fda1-423f-b76f-67e9d24b9644" + }, + "yes_bid_dollars": "0.4100", + "yes_ask_dollars": "0.4300", + "no_bid_dollars": "0.5700", + "no_ask_dollars": "0.5900", + "last_price_dollars": "0.4100", + "volume_fp": "22.07", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBASPREAD-26OCT23NOPUTA-UTA2", + "event_ticker": "KXNBASPREAD-26OCT23NOPUTA", + "market_type": "binary", + "title": "Utah wins by over 1.5 points", + "yes_sub_title": "Utah wins by over 1.5 points", + "no_sub_title": "Utah wins by over 1.5 points", + "status": "active", + "strike_type": "greater", + "floor_strike": 1.5, + "custom_strike": { + "basketball_team": "6f60dfb1-fda1-423f-b76f-67e9d24b9644" + }, + "yes_bid_dollars": "0.5900", + "yes_ask_dollars": "0.6000", + "no_bid_dollars": "0.4000", + "no_ask_dollars": "0.4100", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T05:27:31.492937Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNBAGAME-26OCT23NOPUTA", + "series_ticker": "KXNBAGAME", + "title": "New Orleans vs Utah", + "sub_title": "NOP vs UTA (Oct 23)", + "category": "Sports", + "mutually_exclusive": true, + "product_metadata": { + "competition": "Pro Basketball (M)", + "competition_scope": "Game" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNBAGAME-26OCT23NOPUTA-NOP", + "event_ticker": "KXNBAGAME-26OCT23NOPUTA", + "market_type": "binary", + "title": "New Orleans wins", + "yes_sub_title": "New Orleans", + "no_sub_title": "New Orleans", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "basketball_team": "d3c8c2ac-c4a0-4d67-8b16-53cb91118dfc" + }, + "yes_bid_dollars": "0.3900", + "yes_ask_dollars": "0.4000", + "no_bid_dollars": "0.6000", + "no_ask_dollars": "0.6100", + "last_price_dollars": "0.4000", + "volume_fp": "3283.08", + "open_time": "2026-10-17T01:36:07.566785Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNBAGAME-26OCT23NOPUTA-UTA", + "event_ticker": "KXNBAGAME-26OCT23NOPUTA", + "market_type": "binary", + "title": "Utah wins", + "yes_sub_title": "Utah", + "no_sub_title": "Utah", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "basketball_team": "6f60dfb1-fda1-423f-b76f-67e9d24b9644" + }, + "yes_bid_dollars": "0.6000", + "yes_ask_dollars": "0.6100", + "no_bid_dollars": "0.3900", + "no_ask_dollars": "0.4000", + "last_price_dollars": "0.6100", + "volume_fp": "3668.81", + "open_time": "2026-10-17T01:36:07.566785Z", + "close_time": "2026-10-26T01:30:00Z", + "expected_expiration_time": "2026-10-24T04:30:00Z", + "occurrence_datetime": "2026-10-24T04:30:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNHLTOTAL-26OCT24VGKCBJ", + "series_ticker": "KXNHLTOTAL", + "title": "Vegas vs Columbus: Total Goals", + "sub_title": "VGK vs CBJ (Oct 24)", + "category": "Sports", + "mutually_exclusive": false, + "product_metadata": { + "competition": "Pro Hockey", + "competition_scope": "Point Total" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNHLTOTAL-26OCT24VGKCBJ-7", + "event_ticker": "KXNHLTOTAL-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Full Game: Over 6.5 goals scored", + "yes_sub_title": "Over 6.5 goals scored", + "no_sub_title": "Over 6.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 6.5, + "yes_bid_dollars": "0.4100", + "yes_ask_dollars": "0.5700", + "no_bid_dollars": "0.4300", + "no_ask_dollars": "0.5900", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:11:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNHLTOTAL-26OCT24VGKCBJ-5", + "event_ticker": "KXNHLTOTAL-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Full Game: Over 4.5 goals scored", + "yes_sub_title": "Over 4.5 goals scored", + "no_sub_title": "Over 4.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 4.5, + "yes_bid_dollars": "0.0600", + "yes_ask_dollars": "0.9700", + "no_bid_dollars": "0.0300", + "no_ask_dollars": "0.9400", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:11:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNHLTOTAL-26OCT24VGKCBJ-4", + "event_ticker": "KXNHLTOTAL-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Full Game: Over 3.5 goals scored", + "yes_sub_title": "Over 3.5 goals scored", + "no_sub_title": "Over 3.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 3.5, + "yes_bid_dollars": "0.1000", + "yes_ask_dollars": "0.9800", + "no_bid_dollars": "0.0200", + "no_ask_dollars": "0.9000", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:11:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNHLTOTAL-26OCT24VGKCBJ-8", + "event_ticker": "KXNHLTOTAL-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Full Game: Over 7.5 goals scored", + "yes_sub_title": "Over 7.5 goals scored", + "no_sub_title": "Over 7.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 7.5, + "yes_bid_dollars": "0.0200", + "yes_ask_dollars": "0.8900", + "no_bid_dollars": "0.1100", + "no_ask_dollars": "0.9800", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:11:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNHLTOTAL-26OCT24VGKCBJ-6", + "event_ticker": "KXNHLTOTAL-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Full Game: Over 5.5 goals scored", + "yes_sub_title": "Over 5.5 goals scored", + "no_sub_title": "Over 5.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 5.5, + "yes_bid_dollars": "0.4300", + "yes_ask_dollars": "0.6800", + "no_bid_dollars": "0.3200", + "no_ask_dollars": "0.5700", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:11:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNHLTOTAL-26OCT24VGKCBJ-2", + "event_ticker": "KXNHLTOTAL-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Full Game: Over 1.5 goals scored", + "yes_sub_title": "Over 1.5 goals scored", + "no_sub_title": "Over 1.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 1.5, + "yes_bid_dollars": "0.1500", + "yes_ask_dollars": "0.9700", + "no_bid_dollars": "0.0300", + "no_ask_dollars": "0.8500", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:11:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNHLSPREAD-26OCT24VGKCBJ", + "series_ticker": "KXNHLSPREAD", + "title": "Vegas vs Columbus: Spread", + "sub_title": "VGK vs CBJ (Oct 24)", + "category": "Sports", + "mutually_exclusive": false, + "product_metadata": { + "competition": "Pro Hockey", + "competition_scope": "Spread" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNHLSPREAD-26OCT24VGKCBJ-CBJ3", + "event_ticker": "KXNHLSPREAD-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Columbus wins by over 2.5 goals", + "yes_sub_title": "Columbus wins by over 2.5 goals", + "no_sub_title": "Columbus wins by over 2.5 goals", + "status": "active", + "strike_type": "greater", + "floor_strike": 2.5, + "custom_strike": { + "hockey_team": "253e2161-4b9b-4580-88e7-a25b9f6cf8af" + }, + "yes_bid_dollars": "0.0100", + "yes_ask_dollars": "0.3200", + "no_bid_dollars": "0.6800", + "no_ask_dollars": "0.9900", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:10:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNHLSPREAD-26OCT24VGKCBJ-CBJ2", + "event_ticker": "KXNHLSPREAD-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Columbus wins by over 1.5 goals", + "yes_sub_title": "Columbus wins by over 1.5 goals", + "no_sub_title": "Columbus wins by over 1.5 goals", + "status": "active", + "strike_type": "greater", + "floor_strike": 1.5, + "custom_strike": { + "hockey_team": "253e2161-4b9b-4580-88e7-a25b9f6cf8af" + }, + "yes_bid_dollars": "0.0100", + "yes_ask_dollars": "0.4200", + "no_bid_dollars": "0.5800", + "no_ask_dollars": "0.9900", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:10:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNHLSPREAD-26OCT24VGKCBJ-VGK2", + "event_ticker": "KXNHLSPREAD-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Vegas wins by over 1.5 goals", + "yes_sub_title": "Vegas wins by over 1.5 goals", + "no_sub_title": "Vegas wins by over 1.5 goals", + "status": "active", + "strike_type": "greater", + "floor_strike": 1.5, + "custom_strike": { + "hockey_team": "d599b5cc-5e06-4679-9303-4d5dad7c4cce" + }, + "yes_bid_dollars": "0.1500", + "yes_ask_dollars": "0.5300", + "no_bid_dollars": "0.4700", + "no_ask_dollars": "0.8500", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:10:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNHLSPREAD-26OCT24VGKCBJ-VGK3", + "event_ticker": "KXNHLSPREAD-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Vegas wins by over 2.5 goals", + "yes_sub_title": "Vegas wins by over 2.5 goals", + "no_sub_title": "Vegas wins by over 2.5 goals", + "status": "active", + "strike_type": "greater", + "floor_strike": 2.5, + "custom_strike": { + "hockey_team": "d599b5cc-5e06-4679-9303-4d5dad7c4cce" + }, + "yes_bid_dollars": "0.0100", + "yes_ask_dollars": "0.4200", + "no_bid_dollars": "0.5800", + "no_ask_dollars": "0.9900", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T01:10:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNHLGAME-26OCT24VGKCBJ", + "series_ticker": "KXNHLGAME", + "title": "Vegas vs Columbus", + "sub_title": "VGK vs CBJ (Oct 24)", + "category": "Sports", + "mutually_exclusive": true, + "product_metadata": { + "competition": "Pro Hockey", + "competition_scope": "Game" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNHLGAME-26OCT24VGKCBJ-CBJ", + "event_ticker": "KXNHLGAME-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Columbus wins", + "yes_sub_title": "Columbus", + "no_sub_title": "Columbus", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "hockey_team": "253e2161-4b9b-4580-88e7-a25b9f6cf8af" + }, + "yes_bid_dollars": "0.4100", + "yes_ask_dollars": "0.4500", + "no_bid_dollars": "0.5500", + "no_ask_dollars": "0.5900", + "last_price_dollars": "0.4600", + "volume_fp": "164.78", + "open_time": "2026-10-18T01:19:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNHLGAME-26OCT24VGKCBJ-VGK", + "event_ticker": "KXNHLGAME-26OCT24VGKCBJ", + "market_type": "binary", + "title": "Vegas wins", + "yes_sub_title": "Vegas", + "no_sub_title": "Vegas", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "hockey_team": "d599b5cc-5e06-4679-9303-4d5dad7c4cce" + }, + "yes_bid_dollars": "0.5400", + "yes_ask_dollars": "0.5700", + "no_bid_dollars": "0.4300", + "no_ask_dollars": "0.4600", + "last_price_dollars": "0.5700", + "volume_fp": "157.46", + "open_time": "2026-10-18T01:19:00Z", + "close_time": "2026-10-26T23:00:00Z", + "expected_expiration_time": "2026-10-25T02:00:00Z", + "occurrence_datetime": "2026-10-25T02:00:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXSERIEATOTAL-26OCT28MONNAP", + "series_ticker": "KXSERIEATOTAL", + "title": "Monza vs Napoli: Total Goals", + "sub_title": "MON vs NAP (Oct 28)", + "category": "Sports", + "mutually_exclusive": false, + "product_metadata": { + "competition": "Serie A", + "competition_scope": "Point Total" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXSERIEATOTAL-26OCT28MONNAP-1", + "event_ticker": "KXSERIEATOTAL-26OCT28MONNAP", + "market_type": "binary", + "title": "Will over 0.5 goals be scored?", + "yes_sub_title": "Over 0.5 goals scored", + "no_sub_title": "Over 0.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 0.5, + "yes_bid_dollars": "0.9200", + "yes_ask_dollars": "0.9600", + "no_bid_dollars": "0.0400", + "no_ask_dollars": "0.0800", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEATOTAL-26OCT28MONNAP-2", + "event_ticker": "KXSERIEATOTAL-26OCT28MONNAP", + "market_type": "binary", + "title": "Will over 1.5 goals be scored?", + "yes_sub_title": "Over 1.5 goals scored", + "no_sub_title": "Over 1.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 1.5, + "yes_bid_dollars": "0.7700", + "yes_ask_dollars": "0.8100", + "no_bid_dollars": "0.1900", + "no_ask_dollars": "0.2300", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEATOTAL-26OCT28MONNAP-3", + "event_ticker": "KXSERIEATOTAL-26OCT28MONNAP", + "market_type": "binary", + "title": "Will over 2.5 goals be scored?", + "yes_sub_title": "Over 2.5 goals scored", + "no_sub_title": "Over 2.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 2.5, + "yes_bid_dollars": "0.5300", + "yes_ask_dollars": "0.5700", + "no_bid_dollars": "0.4300", + "no_ask_dollars": "0.4700", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEATOTAL-26OCT28MONNAP-4", + "event_ticker": "KXSERIEATOTAL-26OCT28MONNAP", + "market_type": "binary", + "title": "Will over 3.5 goals be scored?", + "yes_sub_title": "Over 3.5 goals scored", + "no_sub_title": "Over 3.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 3.5, + "yes_bid_dollars": "0.3000", + "yes_ask_dollars": "0.3300", + "no_bid_dollars": "0.6700", + "no_ask_dollars": "0.7000", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEATOTAL-26OCT28MONNAP-5", + "event_ticker": "KXSERIEATOTAL-26OCT28MONNAP", + "market_type": "binary", + "title": "Will over 4.5 goals be scored?", + "yes_sub_title": "Over 4.5 goals scored", + "no_sub_title": "Over 4.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 4.5, + "yes_bid_dollars": "0.1500", + "yes_ask_dollars": "0.1800", + "no_bid_dollars": "0.8200", + "no_ask_dollars": "0.8500", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEATOTAL-26OCT28MONNAP-6", + "event_ticker": "KXSERIEATOTAL-26OCT28MONNAP", + "market_type": "binary", + "title": "Will over 5.5 goals be scored?", + "yes_sub_title": "Over 5.5 goals scored", + "no_sub_title": "Over 5.5 goals scored", + "status": "active", + "strike_type": "greater", + "floor_strike": 5.5, + "yes_bid_dollars": "0.0500", + "yes_ask_dollars": "0.0900", + "no_bid_dollars": "0.9100", + "no_ask_dollars": "0.9500", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXSERIEASPREAD-26OCT28MONNAP", + "series_ticker": "KXSERIEASPREAD", + "title": "Monza vs Napoli: Spread", + "sub_title": "MON vs NAP (Oct 28)", + "category": "Sports", + "mutually_exclusive": false, + "product_metadata": { + "competition": "Serie A", + "competition_scope": "Spread" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXSERIEASPREAD-26OCT28MONNAP-MON3", + "event_ticker": "KXSERIEASPREAD-26OCT28MONNAP", + "market_type": "binary", + "title": "Monza wins by more than 2.5 goals?", + "yes_sub_title": "Monza wins by more than 2.5 goals", + "no_sub_title": "Monza wins by more than 2.5 goals", + "status": "active", + "strike_type": "greater", + "floor_strike": 2.5, + "custom_strike": { + "soccer_team": "da2081c3-6922-47a2-b05e-5474c4705815" + }, + "yes_bid_dollars": "0.0200", + "yes_ask_dollars": "0.1600", + "no_bid_dollars": "0.8400", + "no_ask_dollars": "0.9800", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEASPREAD-26OCT28MONNAP-MON2", + "event_ticker": "KXSERIEASPREAD-26OCT28MONNAP", + "market_type": "binary", + "title": "Monza wins by more than 1.5 goals?", + "yes_sub_title": "Monza wins by more than 1.5 goals", + "no_sub_title": "Monza wins by more than 1.5 goals", + "status": "active", + "strike_type": "greater", + "floor_strike": 1.5, + "custom_strike": { + "soccer_team": "da2081c3-6922-47a2-b05e-5474c4705815" + }, + "yes_bid_dollars": "0.0700", + "yes_ask_dollars": "0.1100", + "no_bid_dollars": "0.8900", + "no_ask_dollars": "0.9300", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEASPREAD-26OCT28MONNAP-NAP2", + "event_ticker": "KXSERIEASPREAD-26OCT28MONNAP", + "market_type": "binary", + "title": "Napoli wins by more than 1.5 goals?", + "yes_sub_title": "Napoli wins by more than 1.5 goals", + "no_sub_title": "Napoli wins by more than 1.5 goals", + "status": "active", + "strike_type": "greater", + "floor_strike": 1.5, + "custom_strike": { + "soccer_team": "721bf91f-9827-4be2-8429-86360529a258" + }, + "yes_bid_dollars": "0.2600", + "yes_ask_dollars": "0.2900", + "no_bid_dollars": "0.7100", + "no_ask_dollars": "0.7400", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEASPREAD-26OCT28MONNAP-NAP3", + "event_ticker": "KXSERIEASPREAD-26OCT28MONNAP", + "market_type": "binary", + "title": "Napoli wins by more than 2.5 goals?", + "yes_sub_title": "Napoli wins by more than 2.5 goals", + "no_sub_title": "Napoli wins by more than 2.5 goals", + "status": "active", + "strike_type": "greater", + "floor_strike": 2.5, + "custom_strike": { + "soccer_team": "721bf91f-9827-4be2-8429-86360529a258" + }, + "yes_bid_dollars": "0.0900", + "yes_ask_dollars": "0.1300", + "no_bid_dollars": "0.8700", + "no_ask_dollars": "0.9100", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-21T20:06:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T23:45:00Z", + "occurrence_datetime": "2026-10-28T23:45:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXSERIEAGAME-26OCT28MONNAP", + "series_ticker": "KXSERIEAGAME", + "title": "Monza vs Napoli", + "sub_title": "MON vs NAP (Oct 28)", + "category": "Sports", + "mutually_exclusive": true, + "product_metadata": { + "competition": "Serie A", + "competition_scope": "Game" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXSERIEAGAME-26OCT28MONNAP-MON", + "event_ticker": "KXSERIEAGAME-26OCT28MONNAP", + "market_type": "binary", + "title": "Monza wins", + "yes_sub_title": "Monza", + "no_sub_title": "Monza", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "soccer_team": "da2081c3-6922-47a2-b05e-5474c4705815" + }, + "yes_bid_dollars": "0.2400", + "yes_ask_dollars": "0.2500", + "no_bid_dollars": "0.7500", + "no_ask_dollars": "0.7600", + "last_price_dollars": "0.2600", + "volume_fp": "182.83", + "open_time": "2026-10-14T00:34:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T22:45:00Z", + "occurrence_datetime": "2026-10-28T22:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEAGAME-26OCT28MONNAP-NAP", + "event_ticker": "KXSERIEAGAME-26OCT28MONNAP", + "market_type": "binary", + "title": "Napoli wins", + "yes_sub_title": "Napoli", + "no_sub_title": "Napoli", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "soccer_team": "721bf91f-9827-4be2-8429-86360529a258" + }, + "yes_bid_dollars": "0.4900", + "yes_ask_dollars": "0.5200", + "no_bid_dollars": "0.4800", + "no_ask_dollars": "0.5100", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-14T00:34:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T22:45:00Z", + "occurrence_datetime": "2026-10-28T22:45:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXSERIEAGAME-26OCT28MONNAP-TIE", + "event_ticker": "KXSERIEAGAME-26OCT28MONNAP", + "market_type": "binary", + "title": "Tie is the result", + "yes_sub_title": "Tie", + "no_sub_title": "Tie", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "soccer_team": "111193d4-9b1f-4bd8-ab7c-9de252737f05" + }, + "yes_bid_dollars": "0.2300", + "yes_ask_dollars": "0.2500", + "no_bid_dollars": "0.7500", + "no_ask_dollars": "0.7700", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-14T00:34:00Z", + "close_time": "2026-10-30T19:45:00Z", + "expected_expiration_time": "2026-10-28T22:45:00Z", + "occurrence_datetime": "2026-10-28T22:45:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXATPMATCH-26OCT23CHICER", + "series_ticker": "KXATPMATCH", + "title": "Chidekh vs Cerundolo", + "sub_title": "Chidekh vs Cerundolo (Oct 23)", + "category": "Sports", + "mutually_exclusive": true, + "product_metadata": { + "competition": "ATP Brussels", + "competition_scope": "Game" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXATPMATCH-26OCT23CHICER-CHI", + "event_ticker": "KXATPMATCH-26OCT23CHICER", + "market_type": "binary", + "title": "Clement Chidekh wins", + "yes_sub_title": "Clement Chidekh", + "no_sub_title": "Clement Chidekh", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "tennis_competitor": "b575dd5b-c370-4fc5-acec-e97951ec571a" + }, + "yes_bid_dollars": "0.3800", + "yes_ask_dollars": "0.4000", + "no_bid_dollars": "0.6000", + "no_ask_dollars": "0.6200", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T11:35:00Z", + "close_time": "2026-11-06T11:30:00Z", + "expected_expiration_time": "2026-10-23T14:30:00Z", + "occurrence_datetime": "2026-10-23T14:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXATPMATCH-26OCT23CHICER-CER", + "event_ticker": "KXATPMATCH-26OCT23CHICER", + "market_type": "binary", + "title": "Francisco Cerundolo wins", + "yes_sub_title": "Francisco Cerundolo", + "no_sub_title": "Francisco Cerundolo", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "tennis_competitor": "c04d61a6-51e7-459c-a881-0734a044c460" + }, + "yes_bid_dollars": "0.6000", + "yes_ask_dollars": "0.6200", + "no_bid_dollars": "0.3800", + "no_ask_dollars": "0.4000", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-22T11:35:00Z", + "close_time": "2026-11-06T11:30:00Z", + "expected_expiration_time": "2026-10-23T14:30:00Z", + "occurrence_datetime": "2026-10-23T14:30:00Z", + "notional_value_dollars": "1.0000" + } + ] + }, + { + "event_ticker": "KXNCAAF1H-26OCT23ARMYTLSA", + "series_ticker": "KXNCAAF1H", + "title": "Army vs Tulsa: 1st Half Winner", + "sub_title": "ARMY vs TLSA (Oct 23)", + "category": "Sports", + "mutually_exclusive": true, + "product_metadata": { + "competition": "NCAA Football", + "competition_scope": "1st Half Winner" + }, + "strike_period": "", + "markets": [ + { + "ticker": "KXNCAAF1H-26OCT23ARMYTLSA-TLSA", + "event_ticker": "KXNCAAF1H-26OCT23ARMYTLSA", + "market_type": "binary", + "title": "Tulsa wins the 1st half", + "yes_sub_title": "Tulsa", + "no_sub_title": "Tulsa", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "football_team": "8cd1c3b1-0e14-4583-b17b-939bb19d0589" + }, + "yes_bid_dollars": "0.4700", + "yes_ask_dollars": "0.4800", + "no_bid_dollars": "0.5200", + "no_ask_dollars": "0.5300", + "last_price_dollars": "0.4900", + "volume_fp": "131.29", + "open_time": "2026-10-10T01:22:00Z", + "close_time": "2026-10-26T00:30:00Z", + "expected_expiration_time": "2026-10-24T03:30:00Z", + "occurrence_datetime": "2026-10-24T03:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNCAAF1H-26OCT23ARMYTLSA-ARMY", + "event_ticker": "KXNCAAF1H-26OCT23ARMYTLSA", + "market_type": "binary", + "title": "Army wins the 1st half", + "yes_sub_title": "Army", + "no_sub_title": "Army", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "football_team": "ddaff44b-6899-434f-bc89-456e3d598be3" + }, + "yes_bid_dollars": "0.4300", + "yes_ask_dollars": "0.4400", + "no_bid_dollars": "0.5600", + "no_ask_dollars": "0.5700", + "last_price_dollars": "0.0000", + "volume_fp": "0.00", + "open_time": "2026-10-10T01:22:00Z", + "close_time": "2026-10-26T00:30:00Z", + "expected_expiration_time": "2026-10-24T03:30:00Z", + "occurrence_datetime": "2026-10-24T03:30:00Z", + "notional_value_dollars": "1.0000" + }, + { + "ticker": "KXNCAAF1H-26OCT23ARMYTLSA-TIE", + "event_ticker": "KXNCAAF1H-26OCT23ARMYTLSA", + "market_type": "binary", + "title": "Tie in the 1st half", + "yes_sub_title": "Tie", + "no_sub_title": "Tie", + "status": "active", + "strike_type": "structured", + "custom_strike": { + "football_team": "fcf3aa12-5bd4-40b1-bbcc-dd677ec662a9" + }, + "yes_bid_dollars": "0.0800", + "yes_ask_dollars": "0.0900", + "no_bid_dollars": "0.9100", + "no_ask_dollars": "0.9200", + "last_price_dollars": "0.1000", + "volume_fp": "10.37", + "open_time": "2026-10-10T01:22:00Z", + "close_time": "2026-10-26T00:30:00Z", + "expected_expiration_time": "2026-10-24T03:30:00Z", + "occurrence_datetime": "2026-10-24T03:30:00Z", + "notional_value_dollars": "1.0000" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/samples/kalshi_rfq_communications_id.json b/tests/samples/kalshi_rfq_communications_id.json new file mode 100644 index 0000000..4fcc5b2 --- /dev/null +++ b/tests/samples/kalshi_rfq_communications_id.json @@ -0,0 +1,3 @@ +{ + "communications_id": "comm-self-7f3a" +} diff --git a/tests/samples/kalshi_rfq_create_combo.json b/tests/samples/kalshi_rfq_create_combo.json new file mode 100644 index 0000000..55ad715 --- /dev/null +++ b/tests/samples/kalshi_rfq_create_combo.json @@ -0,0 +1,27 @@ +{ + "event_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "market": { + "ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "event_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R", + "market_type": "binary", + "status": "active", + "created_time": "2026-10-21T15:01:58Z", + "mve_collection_ticker": "KXMVECROSSCATEGORY-R", + "mve_selected_legs": [ + { + "event_ticker": "KXNFLGAME-26OCT25KCDEN", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "side": "yes" + }, + { + "event_ticker": "KXNBAGAME-26OCT25LALBOS", + "market_ticker": "KXNBAGAME-26OCT25LALBOS-BOS", + "side": "no" + } + ], + "yes_bid_dollars": "0.0000", + "yes_ask_dollars": "0.0000", + "volume_fp": "0.00" + } +} diff --git a/tests/samples/kalshi_rfq_create_quote.json b/tests/samples/kalshi_rfq_create_quote.json new file mode 100644 index 0000000..35a7839 --- /dev/null +++ b/tests/samples/kalshi_rfq_create_quote.json @@ -0,0 +1,3 @@ +{ + "id": "4c5d6e7f-8091-4a2b-b3c4-d5e6f7081920" +} diff --git a/tests/samples/kalshi_rfq_create_rfq.json b/tests/samples/kalshi_rfq_create_rfq.json new file mode 100644 index 0000000..c1126ce --- /dev/null +++ b/tests/samples/kalshi_rfq_create_rfq.json @@ -0,0 +1,3 @@ +{ + "id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11" +} diff --git a/tests/samples/kalshi_rfq_error_insufficient_balance.json b/tests/samples/kalshi_rfq_error_insufficient_balance.json new file mode 100644 index 0000000..e8eadc3 --- /dev/null +++ b/tests/samples/kalshi_rfq_error_insufficient_balance.json @@ -0,0 +1,7 @@ +{ + "error": { + "code": "insufficient_balance", + "message": "insufficient balance", + "service": "communications" + } +} diff --git a/tests/samples/kalshi_rfq_get_collections.json b/tests/samples/kalshi_rfq_get_collections.json new file mode 100644 index 0000000..c6041d1 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_collections.json @@ -0,0 +1,47 @@ +{ + "multivariate_contracts": [ + { + "collection_ticker": "KXMVECROSSCATEGORY-R", + "series_ticker": "KXMVECROSSCATEGORY", + "title": "Cross-category combos", + "description": "Combine markets across categories", + "open_date": "2026-01-01T00:00:00Z", + "close_date": "2027-01-01T00:00:00Z", + "associated_events": [ + { + "ticker": "KXNFLGAME-26OCT25KCDEN", + "is_yes_only": false, + "active_quoters": [ + "comm-maker-91bc" + ] + }, + { + "ticker": "KXNBAGAME-26OCT25LALBOS", + "is_yes_only": false, + "active_quoters": [ + "comm-maker-91bc" + ] + } + ], + "associated_event_tickers": [ + "KXNFLGAME-26OCT25KCDEN", + "KXNBAGAME-26OCT25LALBOS" + ], + "is_ordered": false, + "is_single_market_per_event": true, + "is_all_yes": false, + "size_min": 2, + "size_max": 10, + "functional_description": "Pays $1 if every selected leg resolves on its selected side.", + "price_level_structure": "deci_cent", + "price_ranges": [ + { + "start": "0.0000", + "end": "1.0000", + "step": "0.0010" + } + ] + } + ], + "cursor": "" +} diff --git a/tests/samples/kalshi_rfq_get_fills_rfq.json b/tests/samples/kalshi_rfq_get_fills_rfq.json new file mode 100644 index 0000000..ca80396 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_fills_rfq.json @@ -0,0 +1,23 @@ +{ + "cursor": "", + "fills": [ + { + "action": "buy", + "book_side": "bid", + "count_fp": "5.00", + "created_time": "2026-10-21T15:02:22.501Z", + "fee_cost": "0.090000", + "fill_id": "f1e2d3c4-b5a6-4798-8a9b-0c1d2e3f4a5b", + "is_taker": true, + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "no_price_dollars": "0.5500", + "order_id": "01a1b2c3-d4e5-7f60-8a9b-0c1d2e3f4a5b", + "outcome_side": "yes", + "side": "yes", + "ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "trade_id": "f1e2d3c4-b5a6-4798-8a9b-0c1d2e3f4a5b", + "ts": 1792594942, + "yes_price_dollars": "0.4500" + } + ] +} diff --git a/tests/samples/kalshi_rfq_get_market_combo.json b/tests/samples/kalshi_rfq_get_market_combo.json new file mode 100644 index 0000000..b3f7d65 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_market_combo.json @@ -0,0 +1,25 @@ +{ + "market": { + "ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "event_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R", + "market_type": "binary", + "status": "finalized", + "result": "no", + "created_time": "2026-10-21T15:01:58Z", + "mve_collection_ticker": "KXMVECROSSCATEGORY-R", + "mve_selected_legs": [ + { + "event_ticker": "KXNFLGAME-26OCT25KCDEN", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "side": "yes", + "yes_settlement_value_dollars": "1.0000" + }, + { + "event_ticker": "KXNBAGAME-26OCT25LALBOS", + "market_ticker": "KXNBAGAME-26OCT25LALBOS-BOS", + "side": "no", + "yes_settlement_value_dollars": "1.0000" + } + ] + } +} diff --git a/tests/samples/kalshi_rfq_get_market_leg.json b/tests/samples/kalshi_rfq_get_market_leg.json new file mode 100644 index 0000000..5741d1b --- /dev/null +++ b/tests/samples/kalshi_rfq_get_market_leg.json @@ -0,0 +1,8 @@ +{ + "market": { + "ticker": "KXNBAGAME-26OCT25LALBOS-BOS", + "event_ticker": "KXNBAGAME-26OCT25LALBOS", + "market_type": "binary", + "status": "active" + } +} diff --git a/tests/samples/kalshi_rfq_get_order_rfq.json b/tests/samples/kalshi_rfq_get_order_rfq.json new file mode 100644 index 0000000..cb3d99f --- /dev/null +++ b/tests/samples/kalshi_rfq_get_order_rfq.json @@ -0,0 +1,23 @@ +{ + "order": { + "action": "buy", + "book_side": "bid", + "client_order_id": "", + "created_time": "2026-10-21T15:02:22.501Z", + "fill_count_fp": "5.00", + "initial_count_fp": "5.00", + "last_update_time": "2026-10-21T15:02:22.501Z", + "maker_fees_dollars": "0.000000", + "maker_fill_cost_dollars": "0.000000", + "order_id": "01a1b2c3-d4e5-7f60-8a9b-0c1d2e3f4a5b", + "outcome_side": "yes", + "remaining_count_fp": "0.00", + "side": "yes", + "status": "executed", + "taker_fees_dollars": "0.090000", + "taker_fill_cost_dollars": "2.250000", + "ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "type": "limit", + "yes_price_dollars": "0.4500" + } +} diff --git a/tests/samples/kalshi_rfq_get_quote_accepted.json b/tests/samples/kalshi_rfq_get_quote_accepted.json new file mode 100644 index 0000000..b7307cb --- /dev/null +++ b/tests/samples/kalshi_rfq_get_quote_accepted.json @@ -0,0 +1,22 @@ +{ + "quote": { + "id": "9d41f0aa-6b2e-4f51-8c33-0e7b5a1d2c44", + "rfq_id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-maker-91bc", + "rfq_creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "yes_bid_dollars": "0.4500", + "no_bid_dollars": "0.5500", + "yes_contracts_fp": "5.00", + "no_contracts_fp": "5.00", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:20.114Z", + "status": "accepted", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0, + "accepted_side": "no", + "accepted_ts": "2026-10-21T15:02:20.114Z" + } +} diff --git a/tests/samples/kalshi_rfq_get_quote_cancelled.json b/tests/samples/kalshi_rfq_get_quote_cancelled.json new file mode 100644 index 0000000..1e2f69a --- /dev/null +++ b/tests/samples/kalshi_rfq_get_quote_cancelled.json @@ -0,0 +1,22 @@ +{ + "quote": { + "id": "9d41f0aa-6b2e-4f51-8c33-0e7b5a1d2c44", + "rfq_id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-maker-91bc", + "rfq_creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "yes_bid_dollars": "0.4500", + "no_bid_dollars": "0.5500", + "yes_contracts_fp": "5.00", + "no_contracts_fp": "5.00", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:30.000Z", + "status": "cancelled", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0, + "cancellation_reason": "quote creator cancelled", + "cancelled_ts": "2026-10-21T15:02:30.000Z" + } +} diff --git a/tests/samples/kalshi_rfq_get_quote_confirmed.json b/tests/samples/kalshi_rfq_get_quote_confirmed.json new file mode 100644 index 0000000..a6af8d7 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_quote_confirmed.json @@ -0,0 +1,23 @@ +{ + "quote": { + "id": "9d41f0aa-6b2e-4f51-8c33-0e7b5a1d2c44", + "rfq_id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-maker-91bc", + "rfq_creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "yes_bid_dollars": "0.4500", + "no_bid_dollars": "0.5500", + "yes_contracts_fp": "5.00", + "no_contracts_fp": "5.00", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:21.380Z", + "status": "confirmed", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0, + "accepted_side": "no", + "accepted_ts": "2026-10-21T15:02:20.114Z", + "confirmed_ts": "2026-10-21T15:02:21.380Z" + } +} diff --git a/tests/samples/kalshi_rfq_get_quote_executed.json b/tests/samples/kalshi_rfq_get_quote_executed.json new file mode 100644 index 0000000..e20ef43 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_quote_executed.json @@ -0,0 +1,26 @@ +{ + "quote": { + "id": "9d41f0aa-6b2e-4f51-8c33-0e7b5a1d2c44", + "rfq_id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-maker-91bc", + "rfq_creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "yes_bid_dollars": "0.4500", + "no_bid_dollars": "0.5500", + "yes_contracts_fp": "5.00", + "no_contracts_fp": "5.00", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:22.501Z", + "status": "executed", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0, + "accepted_side": "no", + "accepted_ts": "2026-10-21T15:02:20.114Z", + "confirmed_ts": "2026-10-21T15:02:21.380Z", + "executed_ts": "2026-10-21T15:02:22.501Z", + "rfq_creator_order_id": "01a1b2c3-d4e5-7f60-8a9b-0c1d2e3f4a5b", + "creator_order_id": "01a1b2c3-d4e5-7f60-8a9b-ffffeeee0001" + } +} diff --git a/tests/samples/kalshi_rfq_get_quote_no_only.json b/tests/samples/kalshi_rfq_get_quote_no_only.json new file mode 100644 index 0000000..d977f8a --- /dev/null +++ b/tests/samples/kalshi_rfq_get_quote_no_only.json @@ -0,0 +1,20 @@ +{ + "quote": { + "id": "9d41f0aa-6b2e-4f51-8c33-0e7b5a1d2c44", + "rfq_id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-maker-91bc", + "rfq_creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "yes_bid_dollars": "0.0000", + "no_bid_dollars": "0.5920", + "yes_contracts_fp": "5.00", + "no_contracts_fp": "0.00", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:13.031Z", + "status": "open", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0 + } +} diff --git a/tests/samples/kalshi_rfq_get_quote_open.json b/tests/samples/kalshi_rfq_get_quote_open.json new file mode 100644 index 0000000..21edc79 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_quote_open.json @@ -0,0 +1,20 @@ +{ + "quote": { + "id": "9d41f0aa-6b2e-4f51-8c33-0e7b5a1d2c44", + "rfq_id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-maker-91bc", + "rfq_creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "yes_bid_dollars": "0.4500", + "no_bid_dollars": "0.5500", + "yes_contracts_fp": "5.00", + "no_contracts_fp": "5.00", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:13.031Z", + "status": "open", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0 + } +} diff --git a/tests/samples/kalshi_rfq_get_quotes.json b/tests/samples/kalshi_rfq_get_quotes.json new file mode 100644 index 0000000..7a9fa4e --- /dev/null +++ b/tests/samples/kalshi_rfq_get_quotes.json @@ -0,0 +1,41 @@ +{ + "quotes": [ + { + "id": "9d41f0aa-6b2e-4f51-8c33-0e7b5a1d2c44", + "rfq_id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-maker-91bc", + "rfq_creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "yes_bid_dollars": "0.4500", + "no_bid_dollars": "0.5500", + "yes_contracts_fp": "5.00", + "no_contracts_fp": "5.00", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:13.031Z", + "status": "open", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0 + }, + { + "id": "1f2e3d4c-5b6a-4978-8a7b-6c5d4e3f2a10", + "rfq_id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-other-22de", + "rfq_creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "yes_bid_dollars": "0.0000", + "no_bid_dollars": "0.5920", + "yes_contracts_fp": "5.00", + "no_contracts_fp": "0.00", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:13.031Z", + "status": "open", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0 + } + ], + "cursor": "" +} diff --git a/tests/samples/kalshi_rfq_get_quotes_amount.json b/tests/samples/kalshi_rfq_get_quotes_amount.json new file mode 100644 index 0000000..e310a96 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_quotes_amount.json @@ -0,0 +1,25 @@ +{ + "quotes": [ + { + "id": "2b3c4d5e-6f70-4a81-92b3-c4d5e6f70812", + "rfq_id": "7a7e0f51-2b1d-4d09-8f4e-6c2b3a9d1e07", + "creator_id": "comm-maker-91bc", + "rfq_creator_id": "comm-self-7f3a", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "contracts_fp": "22.72", + "yes_bid_dollars": "0.4000", + "no_bid_dollars": "0.4500", + "yes_contracts_fp": "22.72", + "no_contracts_fp": "20.83", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:13.031Z", + "status": "open", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0, + "rfq_target_cost_dollars": "12.5000", + "target_cost_excludes_fees": true + } + ], + "cursor": "" +} diff --git a/tests/samples/kalshi_rfq_get_quotes_mine.json b/tests/samples/kalshi_rfq_get_quotes_mine.json new file mode 100644 index 0000000..85f190a --- /dev/null +++ b/tests/samples/kalshi_rfq_get_quotes_mine.json @@ -0,0 +1,25 @@ +{ + "quotes": [ + { + "id": "4c5d6e7f-8091-4a2b-b3c4-d5e6f7081920", + "rfq_id": "e8d7c6b5-a4f3-4e2d-9c1b-0a9f8e7d6c5b", + "creator_id": "comm-self-7f3a", + "rfq_creator_id": "comm-other-22de", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "contracts_fp": "20.00", + "yes_bid_dollars": "0.4000", + "no_bid_dollars": "0.5500", + "yes_contracts_fp": "20.00", + "no_contracts_fp": "20.00", + "created_ts": "2026-10-21T15:02:13.031Z", + "updated_ts": "2026-10-21T15:02:13.031Z", + "status": "open", + "rest_remainder": false, + "rfq_creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "rfq_creator_subaccount": 0, + "post_only": true, + "creator_user_id": "488e312d-cc17-405c-9961-d389284068e2" + } + ], + "cursor": "" +} diff --git a/tests/samples/kalshi_rfq_get_rfq.json b/tests/samples/kalshi_rfq_get_rfq.json new file mode 100644 index 0000000..e5aba68 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_rfq.json @@ -0,0 +1,27 @@ +{ + "rfq": { + "id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "status": "open", + "created_ts": "2026-10-21T15:02:11.482Z", + "updated_ts": "2026-10-21T15:02:11.482Z", + "mve_collection_ticker": "KXMVECROSSCATEGORY-R", + "mve_selected_legs": [ + { + "event_ticker": "KXNFLGAME-26OCT25KCDEN", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "side": "yes" + }, + { + "event_ticker": "KXNBAGAME-26OCT25LALBOS", + "market_ticker": "KXNBAGAME-26OCT25LALBOS-BOS", + "side": "no" + } + ], + "rest_remainder": false, + "creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "creator_subaccount": 0 + } +} diff --git a/tests/samples/kalshi_rfq_get_rfq_amount.json b/tests/samples/kalshi_rfq_get_rfq_amount.json new file mode 100644 index 0000000..a40408c --- /dev/null +++ b/tests/samples/kalshi_rfq_get_rfq_amount.json @@ -0,0 +1,18 @@ +{ + "rfq": { + "id": "7a7e0f51-2b1d-4d09-8f4e-6c2b3a9d1e07", + "creator_id": "comm-self-7f3a", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "contracts_fp": "0.00", + "status": "open", + "created_ts": "2026-10-21T15:02:11.482Z", + "updated_ts": "2026-10-21T15:02:11.482Z", + "mve_collection_ticker": "", + "mve_selected_legs": [], + "rest_remainder": false, + "creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "creator_subaccount": 0, + "target_cost_dollars": "12.5000", + "target_cost_excludes_fees": true + } +} diff --git a/tests/samples/kalshi_rfq_get_rfq_expired.json b/tests/samples/kalshi_rfq_get_rfq_expired.json new file mode 100644 index 0000000..1275e19 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_rfq_expired.json @@ -0,0 +1,29 @@ +{ + "rfq": { + "id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "status": "closed", + "created_ts": "2026-10-21T15:02:11.482Z", + "updated_ts": "2026-10-21T15:02:11.482Z", + "mve_collection_ticker": "KXMVECROSSCATEGORY-R", + "mve_selected_legs": [ + { + "event_ticker": "KXNFLGAME-26OCT25KCDEN", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "side": "yes" + }, + { + "event_ticker": "KXNBAGAME-26OCT25LALBOS", + "market_ticker": "KXNBAGAME-26OCT25LALBOS-BOS", + "side": "no" + } + ], + "rest_remainder": false, + "creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "creator_subaccount": 0, + "cancellation_reason": "expired", + "cancelled_ts": "2026-10-21T15:07:11.482Z" + } +} diff --git a/tests/samples/kalshi_rfq_get_rfqs_mine.json b/tests/samples/kalshi_rfq_get_rfqs_mine.json new file mode 100644 index 0000000..eb2f2b4 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_rfqs_mine.json @@ -0,0 +1,57 @@ +{ + "rfqs": [ + { + "id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "status": "open", + "created_ts": "2026-10-21T15:02:11.482Z", + "updated_ts": "2026-10-21T15:02:11.482Z", + "mve_collection_ticker": "KXMVECROSSCATEGORY-R", + "mve_selected_legs": [ + { + "event_ticker": "KXNFLGAME-26OCT25KCDEN", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "side": "yes" + }, + { + "event_ticker": "KXNBAGAME-26OCT25LALBOS", + "market_ticker": "KXNBAGAME-26OCT25LALBOS-BOS", + "side": "no" + } + ], + "rest_remainder": false, + "creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "creator_subaccount": 0 + }, + { + "id": "c3f1d2e4-0a9b-4c8d-b7e6-5f4a3b2c1d0e", + "creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "status": "closed", + "created_ts": "2026-10-21T15:02:11.482Z", + "updated_ts": "2026-10-21T15:02:11.482Z", + "mve_collection_ticker": "KXMVECROSSCATEGORY-R", + "mve_selected_legs": [ + { + "event_ticker": "KXNFLGAME-26OCT25KCDEN", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "side": "yes" + }, + { + "event_ticker": "KXNBAGAME-26OCT25LALBOS", + "market_ticker": "KXNBAGAME-26OCT25LALBOS-BOS", + "side": "no" + } + ], + "rest_remainder": false, + "creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "creator_subaccount": 0, + "cancellation_reason": "cancelled by user", + "cancelled_ts": "2026-10-21T14:10:00Z" + } + ], + "cursor": "" +} diff --git a/tests/samples/kalshi_rfq_get_rfqs_open.json b/tests/samples/kalshi_rfq_get_rfqs_open.json new file mode 100644 index 0000000..3846b25 --- /dev/null +++ b/tests/samples/kalshi_rfq_get_rfqs_open.json @@ -0,0 +1,44 @@ +{ + "rfqs": [ + { + "id": "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11", + "creator_id": "comm-self-7f3a", + "market_ticker": "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW", + "contracts_fp": "5.00", + "status": "open", + "created_ts": "2026-10-21T15:02:11.482Z", + "updated_ts": "2026-10-21T15:02:11.482Z", + "mve_collection_ticker": "KXMVECROSSCATEGORY-R", + "mve_selected_legs": [ + { + "event_ticker": "KXNFLGAME-26OCT25KCDEN", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "side": "yes" + }, + { + "event_ticker": "KXNBAGAME-26OCT25LALBOS", + "market_ticker": "KXNBAGAME-26OCT25LALBOS-BOS", + "side": "no" + } + ], + "rest_remainder": false, + "creator_user_id": "488e312d-cc17-405c-9961-d389284068e2", + "creator_subaccount": 0 + }, + { + "id": "e8d7c6b5-a4f3-4e2d-9c1b-0a9f8e7d6c5b", + "creator_id": "comm-other-22de", + "market_ticker": "KXNFLGAME-26OCT25KCDEN-KC", + "contracts_fp": "20.00", + "status": "open", + "created_ts": "2026-10-21T15:02:11.482Z", + "updated_ts": "2026-10-21T15:02:11.482Z", + "mve_collection_ticker": "", + "mve_selected_legs": [], + "rest_remainder": false, + "creator_user_id": "", + "creator_subaccount": 0 + } + ], + "cursor": "" +} diff --git a/tests/samples/polymarket_combo_board_live.json b/tests/samples/polymarket_combo_board_live.json new file mode 100644 index 0000000..0f3af42 --- /dev/null +++ b/tests/samples/polymarket_combo_board_live.json @@ -0,0 +1,733 @@ +{ + "source": "GET combos-rfq-api.polymarket.com/v1/rfq/combo-markets (rows), gamma /markets?id= (details), gamma /events?id= (teams); read 2026-10-22, trimmed", + "catalogue": [ + { + "id": "5014980", + "condition_id": "0x3e4d20cb627ab0bcfab0e7fb48753765b3436dd121999befd2596572a6d2dc36", + "position_ids": [ + "1239457920713117704534553494506711257917271190281025660857141456714128687616", + "1239457920713117704534553494506711257917271190281025660857141456714128687617" + ], + "slug": "lal-ray-bil-2026-10-10-bil", + "title": "Will Athletic Club win on 2026-10-10?", + "outcomes": [ + "Yes", + "No" + ], + "outcome_prices": [ + "0.5", + "0.5" + ], + "volume": 764387.5188169999, + "tags": [ + "sports", + "la-liga", + "soccer", + "games" + ], + "pending": false + }, + { + "id": "5014977", + "condition_id": "0xa0996a2e100ea502040335bf116a5ce635fa214d25b8ecd6e8eeb0d1541575de", + "position_ids": [ + "1239457920713117704534553494506711257917271190281025660857141456714128687104", + "1239457920713117704534553494506711257917271190281025660857141456714128687105" + ], + "slug": "lal-ray-bil-2026-10-10-ray", + "title": "Will Rayo Vallecano de Madrid win on 2026-10-10?", + "outcomes": [ + "Yes", + "No" + ], + "outcome_prices": [ + "0.32", + "0.68" + ], + "volume": 205964.87766600004, + "tags": [ + "sports", + "la-liga", + "soccer", + "games" + ], + "pending": false + }, + { + "id": "5967419", + "condition_id": "0x995bb030524039762f8d337f0b0a3f20cbaf244fdbfb67983ba034e40e10bee9", + "position_ids": [ + "812191587312213181417248947560128116875892949840435554144929469083613659136", + "812191587312213181417248947560128116875892949840435554144929469083613659137" + ], + "slug": "atp-medvede-shimabu-2026-10-21", + "title": "Almaty Open: Daniil Medvedev vs Sho Shimabukuro", + "outcomes": [ + "Daniil Medvedev", + "Sho Shimabukuro" + ], + "outcome_prices": [ + "0.9515", + "0.0485" + ], + "volume": 171434.457155, + "tags": [ + "sports", + "tennis", + "games" + ], + "pending": false + }, + { + "id": "5765067", + "condition_id": "0x3b9b2e4526991af7bbad2425c277b70c74dde43ca61967932ed014610f1335cb", + "position_ids": [ + "658798547295106558391642696230774514472965554575455656445630441708624805888", + "658798547295106558391642696230774514472965554575455656445630441708624805889" + ], + "slug": "nba-cle-phi-2026-10-22-spread-home-3pt5", + "title": "Spread: 76ers (-3.5)", + "outcomes": [ + "76ers", + "Cavaliers" + ], + "outcome_prices": [ + "0.495", + "0.505" + ], + "volume": 133226.919217, + "tags": [ + "sports", + "basketball", + "nba", + "games", + "nba-tip-off" + ], + "pending": false + }, + { + "id": "5765055", + "condition_id": "0xfa657d6b58e1b7cb79969d206b46093a17d6b16b8789c9308f46e4b87d7da056", + "position_ids": [ + "494432088026114895012896450180472548395277809254358325375579591193263079424", + "494432088026114895012896450180472548395277809254358325375579591193263079425" + ], + "slug": "nba-cle-phi-2026-10-22", + "title": "Cavaliers vs. 76ers", + "outcomes": [ + "Cavaliers", + "76ers" + ], + "outcome_prices": [ + "0.415", + "0.585" + ], + "volume": 130031.30335100002, + "tags": [ + "sports", + "basketball", + "nba", + "games", + "nba-tip-off" + ], + "pending": false + }, + { + "id": "4465810", + "condition_id": "0x3274464d5a09edb00cdb2f4e9243531eb9bb0a7a1003d6190f42f2f6a9f1f6c5", + "position_ids": [ + "780470464588271762802508174418332605074779093285886628669634290926548418560", + "780470464588271762802508174418332605074779093285886628669634290926548418561" + ], + "slug": "nfl-ne-chi-2026-10-23", + "title": "Patriots vs. Bears", + "outcomes": [ + "Patriots", + "Bears" + ], + "outcome_prices": [ + "0.405", + "0.595" + ], + "volume": 50173.165697000004, + "tags": [ + "sports", + "nfl", + "games" + ], + "pending": false + }, + { + "id": "5377525", + "condition_id": "0xf8f233c9e72b352ec90537beee7a0decfac312ee822cbbd02bcae59da1266807", + "position_ids": [ + "895370965712402838845075814655491244110382657687787990890177830914696937472", + "895370965712402838845075814655491244110382657687787990890177830914696937473" + ], + "slug": "nfl-ne-chi-2026-10-23-total-50pt5", + "title": "Patriots vs. Bears: O/U 50.5", + "outcomes": [ + "Over", + "Under" + ], + "outcome_prices": [ + "0.515", + "0.485" + ], + "volume": 48026.659222, + "tags": [ + "sports", + "nfl", + "games" + ], + "pending": false + }, + { + "id": "5014978", + "condition_id": "0x5a5587793833810ba3e06d42174c0adfacf00c63037a2a10a487096b8c206eaf", + "position_ids": [ + "1239457920713117704534553494506711257917271190281025660857141456714128687360", + "1239457920713117704534553494506711257917271190281025660857141456714128687361" + ], + "slug": "lal-ray-bil-2026-10-10-draw", + "title": "Will Rayo Vallecano de Madrid vs. Athletic Club end in a draw?", + "outcomes": [ + "Yes", + "No" + ], + "outcome_prices": [ + "0.41", + "0.59" + ], + "volume": 28728.108336999994, + "tags": [ + "sports", + "la-liga", + "soccer", + "games" + ], + "pending": false + }, + { + "id": "5377302", + "condition_id": "0x9737a2f03e3ebe8e9ec95bfb4d68739f67a3532a326b6ff2f95d68dcb21508e2", + "position_ids": [ + "635425323029397911853511026829601424252347407657425360521560022597454790656", + "635425323029397911853511026829601424252347407657425360521560022597454790657" + ], + "slug": "nfl-ne-chi-2026-10-23-spread-home-3pt5", + "title": "Spread: Bears (-3.5)", + "outcomes": [ + "Bears", + "Patriots" + ], + "outcome_prices": [ + "0.445", + "0.555" + ], + "volume": 25097.824566000003, + "tags": [ + "sports", + "nfl", + "games" + ], + "pending": false + }, + { + "id": "5377299", + "condition_id": "0x5d68e8a797b3aed418b2f7a51d3f98b0bf3db39c04bdbdd0d46c102f005dba91", + "position_ids": [ + "790206486744213347314617173665270136520900268061448387742451571526845071360", + "790206486744213347314617173665270136520900268061448387742451571526845071361" + ], + "slug": "nfl-ne-chi-2026-10-23-spread-home-2pt5", + "title": "Spread: Bears (-2.5)", + "outcomes": [ + "Bears", + "Patriots" + ], + "outcome_prices": [ + "0.545", + "0.455" + ], + "volume": 20394.626246, + "tags": [ + "sports", + "nfl", + "games" + ], + "pending": false + }, + { + "id": "5910788", + "condition_id": "0xa4b27f1811e93f2ebb2c8c67a90cd5541a87da483468b7caffea2d2220943b0e", + "position_ids": [ + "499188492896739380518319807727139595744760227720852297402384515454587633664", + "499188492896739380518319807727139595744760227720852297402384515454587633665" + ], + "slug": "nba-cle-phi-2026-10-22-total-235pt5", + "title": "Cavaliers vs. 76ers: O/U 235.5", + "outcomes": [ + "Over", + "Under" + ], + "outcome_prices": [ + "0.515", + "0.485" + ], + "volume": 11532.613890999999, + "tags": [ + "sports", + "basketball", + "nba", + "games", + "nba-tip-off" + ], + "pending": false + }, + { + "id": "5765056", + "condition_id": "0x3faa9080a057b17670e3281d6a1c6a7ed990611f835bba7e5b89b666d63fd330", + "position_ids": [ + "836715131547659060669121644089886404503232887056217848539681828502704750592", + "836715131547659060669121644089886404503232887056217848539681828502704750593" + ], + "slug": "nba-cle-phi-2026-10-22-spread-home-2pt5", + "title": "Spread: 76ers (-2.5)", + "outcomes": [ + "76ers", + "Cavaliers" + ], + "outcome_prices": [ + "0.535", + "0.465" + ], + "volume": 9671.510871, + "tags": [ + "sports", + "basketball", + "nba", + "games", + "nba-tip-off" + ], + "pending": false + }, + { + "id": "665374", + "condition_id": "0x5db999fad322cea2914535aae5517060c3f80ad6d8c0231cde2124a434d16846", + "position_ids": [ + "798559951534518479645224261511384773234863312866932338530531601041078616064", + "798559951534518479645224261511384773234863312866932338530531601041078616065" + ], + "slug": "will-the-us-invade-iran-before-2027", + "title": "Will the U.S. invade Iran before 2027?", + "outcomes": [ + "Yes", + "No" + ], + "outcome_prices": [ + "0.165", + "0.835" + ], + "volume": 74992158.89709401, + "tags": [ + "politics", + "iran", + "trump", + "middle-east", + "israel", + "geopolitics", + "world", + "military-strikes" + ], + "pending": false + } + ], + "gamma_markets": [ + { + "id": "5014980", + "question": "Will Athletic Club win on 2026-10-10?", + "slug": "lal-ray-bil-2026-10-10-bil", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0.5\", \"0.5\"]", + "sportsMarketType": "moneyline", + "groupItemTitle": "Athletic Club", + "gameStartTime": "2026-10-10 12:00:00+00", + "bestBid": 0.43, + "bestAsk": 0.57, + "volume": "764387.5188169999", + "endDate": "2026-10-10T12:00:00Z", + "events": [ + { + "id": "1089916", + "title": "Rayo Vallecano de Madrid vs. Athletic Club", + "slug": "lal-ray-bil-2026-10-10", + "startTime": "2026-10-10T12:00:00Z", + "seriesSlug": "la-liga-2025" + } + ] + }, + { + "id": "5014977", + "question": "Will Rayo Vallecano de Madrid win on 2026-10-10?", + "slug": "lal-ray-bil-2026-10-10-ray", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0.32\", \"0.68\"]", + "sportsMarketType": "moneyline", + "groupItemTitle": "Rayo Vallecano de Madrid", + "gameStartTime": "2026-10-10 12:00:00+00", + "bestBid": 0.27, + "bestAsk": 0.37, + "volume": "205964.87766600004", + "endDate": "2026-10-10T12:00:00Z", + "events": [ + { + "id": "1089916", + "title": "Rayo Vallecano de Madrid vs. Athletic Club", + "slug": "lal-ray-bil-2026-10-10", + "startTime": "2026-10-10T12:00:00Z", + "seriesSlug": "la-liga-2025" + } + ] + }, + { + "id": "5967419", + "question": "Almaty Open: Daniil Medvedev vs Sho Shimabukuro", + "slug": "atp-medvede-shimabu-2026-10-21", + "outcomes": "[\"Daniil Medvedev\", \"Sho Shimabukuro\"]", + "outcomePrices": "[\"0.9655\", \"0.0345\"]", + "sportsMarketType": "moneyline", + "gameStartTime": "2026-10-22 12:40:00+00", + "bestBid": 0.965, + "bestAsk": 0.966, + "volume": "171434.457155", + "endDate": "2026-10-28T06:00:00Z", + "events": [ + { + "id": "1223267", + "title": "Almaty Open: Daniil Medvedev vs Sho Shimabukuro", + "slug": "atp-medvede-shimabu-2026-10-21", + "startTime": "2026-10-22T12:40:00Z", + "seriesSlug": "atp" + } + ] + }, + { + "id": "5765067", + "question": "Spread: 76ers (-3.5)", + "slug": "nba-cle-phi-2026-10-22-spread-home-3pt5", + "outcomes": "[\"76ers\", \"Cavaliers\"]", + "outcomePrices": "[\"0.495\", \"0.505\"]", + "sportsMarketType": "spreads", + "line": -3.5, + "groupItemTitle": "Spread -3.5", + "gameStartTime": "2026-10-22 23:00:00+00", + "bestBid": 0.49, + "bestAsk": 0.5, + "volume": "133226.919217", + "endDate": "2026-10-22T23:00:00Z", + "events": [ + { + "id": "1197295", + "title": "Cavaliers vs. 76ers", + "slug": "nba-cle-phi-2026-10-22", + "startTime": "2026-10-22T23:00:00Z", + "seriesSlug": "nba-2026" + } + ] + }, + { + "id": "5765055", + "question": "Cavaliers vs. 76ers", + "slug": "nba-cle-phi-2026-10-22", + "outcomes": "[\"Cavaliers\", \"76ers\"]", + "outcomePrices": "[\"0.415\", \"0.585\"]", + "sportsMarketType": "moneyline", + "gameStartTime": "2026-10-22 23:00:00+00", + "bestBid": 0.41, + "bestAsk": 0.42, + "volume": "130031.30335100002", + "endDate": "2026-10-22T23:00:00Z", + "events": [ + { + "id": "1197295", + "title": "Cavaliers vs. 76ers", + "slug": "nba-cle-phi-2026-10-22", + "startTime": "2026-10-22T23:00:00Z", + "seriesSlug": "nba-2026" + } + ] + }, + { + "id": "4465810", + "question": "Patriots vs. Bears", + "slug": "nfl-ne-chi-2026-10-23", + "outcomes": "[\"Patriots\", \"Bears\"]", + "outcomePrices": "[\"0.405\", \"0.595\"]", + "sportsMarketType": "moneyline", + "gameStartTime": "2026-10-23 00:15:00+00", + "bestBid": 0.4, + "bestAsk": 0.41, + "volume": "50173.165697000004", + "endDate": "2026-10-23T00:15:00Z", + "events": [ + { + "id": "1004101", + "title": "Patriots vs. Bears", + "slug": "nfl-ne-chi-2026-10-23", + "startTime": "2026-10-23T00:15:00Z", + "seriesSlug": "nfl-2026" + } + ] + }, + { + "id": "5377525", + "question": "Patriots vs. Bears: O/U 50.5", + "slug": "nfl-ne-chi-2026-10-23-total-50pt5", + "outcomes": "[\"Over\", \"Under\"]", + "outcomePrices": "[\"0.515\", \"0.485\"]", + "sportsMarketType": "totals", + "line": 50.5, + "groupItemTitle": "O/U 50.5", + "gameStartTime": "2026-10-23 00:15:00+00", + "bestBid": 0.51, + "bestAsk": 0.52, + "volume": "48026.659222", + "endDate": "2026-10-23T00:15:00Z", + "events": [ + { + "id": "1004101", + "title": "Patriots vs. Bears", + "slug": "nfl-ne-chi-2026-10-23", + "startTime": "2026-10-23T00:15:00Z", + "seriesSlug": "nfl-2026" + } + ] + }, + { + "id": "5014978", + "question": "Will Rayo Vallecano de Madrid vs. Athletic Club end in a draw?", + "slug": "lal-ray-bil-2026-10-10-draw", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0.41\", \"0.59\"]", + "sportsMarketType": "moneyline", + "groupItemTitle": "Draw (Rayo Vallecano de Madrid vs. Athletic Club)", + "gameStartTime": "2026-10-10 12:00:00+00", + "bestBid": 0.31, + "bestAsk": 0.51, + "volume": "28728.108336999994", + "endDate": "2026-10-10T12:00:00Z", + "events": [ + { + "id": "1089916", + "title": "Rayo Vallecano de Madrid vs. Athletic Club", + "slug": "lal-ray-bil-2026-10-10", + "startTime": "2026-10-10T12:00:00Z", + "seriesSlug": "la-liga-2025" + } + ] + }, + { + "id": "5377302", + "question": "Spread: Bears (-3.5)", + "slug": "nfl-ne-chi-2026-10-23-spread-home-3pt5", + "outcomes": "[\"Bears\", \"Patriots\"]", + "outcomePrices": "[\"0.445\", \"0.555\"]", + "sportsMarketType": "spreads", + "line": -3.5, + "groupItemTitle": "Spread -3.5", + "gameStartTime": "2026-10-23 00:15:00+00", + "bestBid": 0.44, + "bestAsk": 0.45, + "volume": "25097.824566000003", + "endDate": "2026-10-23T00:15:00Z", + "events": [ + { + "id": "1004101", + "title": "Patriots vs. Bears", + "slug": "nfl-ne-chi-2026-10-23", + "startTime": "2026-10-23T00:15:00Z", + "seriesSlug": "nfl-2026" + } + ] + }, + { + "id": "5377299", + "question": "Spread: Bears (-2.5)", + "slug": "nfl-ne-chi-2026-10-23-spread-home-2pt5", + "outcomes": "[\"Bears\", \"Patriots\"]", + "outcomePrices": "[\"0.545\", \"0.455\"]", + "sportsMarketType": "spreads", + "line": -2.5, + "groupItemTitle": "Spread -2.5", + "gameStartTime": "2026-10-23 00:15:00+00", + "bestBid": 0.54, + "bestAsk": 0.55, + "volume": "20394.626246", + "endDate": "2026-10-23T00:15:00Z", + "events": [ + { + "id": "1004101", + "title": "Patriots vs. Bears", + "slug": "nfl-ne-chi-2026-10-23", + "startTime": "2026-10-23T00:15:00Z", + "seriesSlug": "nfl-2026" + } + ] + }, + { + "id": "5910788", + "question": "Cavaliers vs. 76ers: O/U 235.5", + "slug": "nba-cle-phi-2026-10-22-total-235pt5", + "outcomes": "[\"Over\", \"Under\"]", + "outcomePrices": "[\"0.515\", \"0.485\"]", + "sportsMarketType": "totals", + "line": 235.5, + "groupItemTitle": "O/U 235.5", + "gameStartTime": "2026-10-22 23:00:00+00", + "bestBid": 0.51, + "bestAsk": 0.52, + "volume": "11532.613890999999", + "endDate": "2026-10-22T23:00:00Z", + "events": [ + { + "id": "1197295", + "title": "Cavaliers vs. 76ers", + "slug": "nba-cle-phi-2026-10-22", + "startTime": "2026-10-22T23:00:00Z", + "seriesSlug": "nba-2026" + } + ] + }, + { + "id": "5765056", + "question": "Spread: 76ers (-2.5)", + "slug": "nba-cle-phi-2026-10-22-spread-home-2pt5", + "outcomes": "[\"76ers\", \"Cavaliers\"]", + "outcomePrices": "[\"0.535\", \"0.465\"]", + "sportsMarketType": "spreads", + "line": -2.5, + "gameStartTime": "2026-10-22 23:00:00+00", + "bestBid": 0.53, + "bestAsk": 0.54, + "volume": "9671.510871", + "endDate": "2026-10-22T23:00:00Z", + "events": [ + { + "id": "1197295", + "title": "Cavaliers vs. 76ers", + "slug": "nba-cle-phi-2026-10-22", + "startTime": "2026-10-22T23:00:00Z", + "seriesSlug": "nba-2026" + } + ] + }, + { + "id": "665374", + "question": "Will the U.S. invade Iran before 2027?", + "slug": "will-the-us-invade-iran-before-2027", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0.165\", \"0.835\"]", + "groupItemTitle": "", + "bestBid": 0.16, + "bestAsk": 0.17, + "volume": "74992158.89709401", + "endDate": "2027-01-01T04:59:00Z", + "events": [ + { + "id": "73130", + "title": "Will the U.S. invade Iran before 2027?", + "slug": "will-the-us-invade-iran-before-2027", + "startTime": null, + "seriesSlug": "us-to-invade-iran" + } + ] + } + ], + "gamma_events": [ + { + "id": "1004101", + "title": "Patriots vs. Bears", + "slug": "nfl-ne-chi-2026-10-23", + "teams": [ + { + "id": 100020, + "name": "New England Patriots", + "abbreviation": "ne", + "alias": "Patriots", + "ordering": "away" + }, + { + "id": 100036, + "name": "Chicago Bears", + "abbreviation": "chi", + "alias": "Bears", + "ordering": "home" + } + ] + }, + { + "id": "1089916", + "title": "Rayo Vallecano de Madrid vs. Athletic Club", + "slug": "lal-ray-bil-2026-10-10", + "teams": [ + { + "id": 101046, + "name": "Rayo Vallecano de Madrid", + "abbreviation": "ray", + "alias": "Rayo Vallecano", + "ordering": "home" + }, + { + "id": 101047, + "name": "Athletic Club", + "abbreviation": "bil", + "alias": "Athletic", + "ordering": "away" + } + ] + }, + { + "id": "1197295", + "title": "Cavaliers vs. 76ers", + "slug": "nba-cle-phi-2026-10-22", + "teams": [ + { + "id": 100513, + "name": "Cavaliers", + "abbreviation": "cle", + "alias": "Cavaliers", + "ordering": "away" + }, + { + "id": 100510, + "name": "76ers", + "abbreviation": "phi", + "alias": "76ers", + "ordering": "home" + } + ] + }, + { + "id": "1223267", + "title": "Almaty Open: Daniil Medvedev vs Sho Shimabukuro", + "slug": "atp-medvede-shimabu-2026-10-21", + "teams": [ + { + "id": 147120, + "name": "Daniil Medvedev", + "abbreviation": "medvede", + "alias": "D. Medvedev", + "ordering": "home" + }, + { + "id": 147152, + "name": "Sho Shimabukuro", + "abbreviation": "shimabu", + "alias": "S. Shimabukuro", + "ordering": "away" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/samples/polymarket_combo_skip_legs_live.json b/tests/samples/polymarket_combo_skip_legs_live.json new file mode 100644 index 0000000..1e4ed58 --- /dev/null +++ b/tests/samples/polymarket_combo_skip_legs_live.json @@ -0,0 +1,160 @@ +{ + "_source": "gamma-api.polymarket.com, no auth, 2026-10-22: GET /markets?id=... and GET /events?slug=spl-haz-fay-2026-10-22 (trimmed)", + "gamma_markets": [ + { + "id": "665374", + "question": "Will the U.S. invade Iran before 2027?", + "conditionId": "0x5db999fad322cea2914535aae5517060c3f80ad6d8c0231cde2124a434d16846", + "slug": "will-the-us-invade-iran-before-2027", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0.165\", \"0.835\"]", + "groupItemTitle": "", + "endDate": "2027-01-01T04:59:00Z", + "bestBid": 0.16, + "bestAsk": 0.17, + "volume": "74992514.50771101", + "active": true, + "closed": false, + "clobTokenIds": "[\"55115078421062885512539156303747803058407616201213034911037320915726138659123\", \"1910830010387565971650098373488592514702818137344973088263643820608151819241\"]", + "events": [ + { + "id": "73130", + "title": "Will the U.S. invade Iran before 2027?", + "slug": "will-the-us-invade-iran-before-2027", + "seriesSlug": "us-to-invade-iran" + } + ] + }, + { + "id": "5441699", + "question": "Will Al Hazem SC win on 2026-10-22?", + "conditionId": "0x018ec4ff84aa4be8dbc516f219330875b24d62f6de5d01322d28980f32ab9270", + "slug": "spl-haz-fay-2026-10-22-haz", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0.055\", \"0.945\"]", + "groupItemTitle": "Al Hazem SC", + "sportsMarketType": "moneyline", + "gameStartTime": "2026-10-22 13:40:00+00", + "endDate": "2026-10-22T13:40:00Z", + "bestBid": 0.05, + "bestAsk": 0.06, + "volume": "34255.961589000006", + "active": true, + "closed": false, + "clobTokenIds": "[\"55820637656080051236779553744171718550430743317298849483643993923931369327674\", \"49887698749361420283253703773210173901670246597450902289338216286026577039943\"]", + "events": [ + { + "id": "1155505", + "title": "Al Hazem SC vs. Al Fayha Saudi Club", + "slug": "spl-haz-fay-2026-10-22", + "seriesSlug": "saudi-professional-league", + "startTime": "2026-10-22T13:40:00Z" + } + ] + }, + { + "id": "5441700", + "question": "Will Al Hazem SC vs. Al Fayha Saudi Club end in a draw?", + "conditionId": "0xcb917d0199f13258315a4d139a984b864cb9d69b958de0dff8e5e9260bc5182a", + "slug": "spl-haz-fay-2026-10-22-draw", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0.245\", \"0.755\"]", + "groupItemTitle": "Draw (Al Hazem SC vs. Al Fayha Saudi Club)", + "sportsMarketType": "moneyline", + "gameStartTime": "2026-10-22 13:40:00+00", + "endDate": "2026-10-22T13:40:00Z", + "bestBid": 0.24, + "bestAsk": 0.25, + "volume": "4995.146306", + "active": true, + "closed": false, + "clobTokenIds": "[\"45724549326613598249462400116700482919332896353917976499269550515147661134216\", \"59241911495708626709881233002614477825022443457172355568157022255230177659246\"]", + "events": [ + { + "id": "1155505", + "title": "Al Hazem SC vs. Al Fayha Saudi Club", + "slug": "spl-haz-fay-2026-10-22", + "seriesSlug": "saudi-professional-league", + "startTime": "2026-10-22T13:40:00Z" + } + ] + }, + { + "id": "5441701", + "question": "Will Al Fayha Saudi Club win on 2026-10-22?", + "conditionId": "0x16532e62e59a43814d88904862e72f499e732564a12538eaff19bbe72ced22ee", + "slug": "spl-haz-fay-2026-10-22-fay", + "outcomes": "[\"Yes\", \"No\"]", + "outcomePrices": "[\"0.705\", \"0.295\"]", + "groupItemTitle": "Al Fayha Saudi Club", + "sportsMarketType": "moneyline", + "gameStartTime": "2026-10-22 13:40:00+00", + "endDate": "2026-10-22T13:40:00Z", + "bestBid": 0.7, + "bestAsk": 0.71, + "volume": "44744.349081", + "active": true, + "closed": false, + "clobTokenIds": "[\"89021557173236511193207323280147232960197796554895843150971900545567946374202\", \"72594579263945692457243696883608040759678572474107456550546424327901331244223\"]", + "events": [ + { + "id": "1155505", + "title": "Al Hazem SC vs. Al Fayha Saudi Club", + "slug": "spl-haz-fay-2026-10-22", + "seriesSlug": "saudi-professional-league", + "startTime": "2026-10-22T13:40:00Z" + } + ] + } + ], + "gamma_events": [ + { + "id": "1155505", + "title": "Al Hazem SC vs. Al Fayha Saudi Club", + "slug": "spl-haz-fay-2026-10-22", + "seriesSlug": "saudi-professional-league", + "startTime": "2026-10-22T13:40:00Z", + "teams": [ + { + "id": 119066, + "name": "Al Hazem SC", + "abbreviation": "haz", + "alias": "Al Hazem", + "league": "spl", + "ordering": "home" + }, + { + "id": 119054, + "name": "Al Fayha Saudi Club", + "abbreviation": "fay", + "alias": "Al Fayha", + "league": "spl", + "ordering": "away" + } + ], + "tags": [ + { + "id": "1", + "label": "Sports", + "slug": "sports" + }, + { + "id": "100639", + "label": "Games", + "slug": "games" + }, + { + "id": "100350", + "label": "Soccer", + "slug": "soccer" + }, + { + "id": "102650", + "label": "Saudi Professional League", + "slug": "saudi-professional-league" + } + ], + "markets": [] + } + ] +} \ No newline at end of file diff --git a/tests/samples/polymarket_rfq_accept_declined.json b/tests/samples/polymarket_rfq_accept_declined.json new file mode 100644 index 0000000..76352a6 --- /dev/null +++ b/tests/samples/polymarket_rfq_accept_declined.json @@ -0,0 +1,8 @@ +{ + "rfq_id": "rfq_0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "status": "FAILED", + "error": { + "code": "MAKER_DECLINED", + "message": "maker declined" + } +} diff --git a/tests/samples/polymarket_rfq_accept_executing.json b/tests/samples/polymarket_rfq_accept_executing.json new file mode 100644 index 0000000..498f5ec --- /dev/null +++ b/tests/samples/polymarket_rfq_accept_executing.json @@ -0,0 +1,5 @@ +{ + "rfq_id": "rfq_0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "status": "EXECUTING", + "taker_order_hash": "0xTAKERHASH" +} diff --git a/tests/samples/polymarket_rfq_combo_markets.json b/tests/samples/polymarket_rfq_combo_markets.json new file mode 100644 index 0000000..93fd1c9 --- /dev/null +++ b/tests/samples/polymarket_rfq_combo_markets.json @@ -0,0 +1,52 @@ +{ + "markets": [ + { + "id": "1897034", + "condition_id": "0x4cd7000000000000000000000000000000000000000000000000000000000000", + "position_ids": [ + "101", + "102" + ], + "slug": "fifwc-mex-rsa-2026-06-11-mex", + "title": "Will Mexico win on 2026-06-11?", + "outcomes": [ + "Yes", + "No" + ], + "outcome_prices": [ + "0.685", + "0.315" + ], + "image": "https://example.test/a.png", + "volume": 330327.71, + "tags": [ + "sports", + "soccer" + ] + }, + { + "id": "1897035", + "condition_id": "0x0391000000000000000000000000000000000000000000000000000000000000", + "position_ids": [ + "201", + "202" + ], + "slug": "fifwc-total-goals-over-2", + "title": "Over 2.5 goals?", + "outcomes": [ + "Yes", + "No" + ], + "outcome_prices": [ + "0.52", + "0.48" + ], + "image": "https://example.test/b.png", + "volume": 120000.5, + "tags": [ + "sports" + ] + } + ], + "next_cursor": "Mg" +} diff --git a/tests/samples/polymarket_rfq_combo_markets_live.json b/tests/samples/polymarket_rfq_combo_markets_live.json new file mode 100644 index 0000000..0408b37 --- /dev/null +++ b/tests/samples/polymarket_rfq_combo_markets_live.json @@ -0,0 +1,57 @@ +{ + "markets": [ + { + "id": "665374", + "condition_id": "0x5db999fad322cea2914535aae5517060c3f80ad6d8c0231cde2124a434d16846", + "position_ids": [ + "798559951534518479645224261511384773234863312866932338530531601041078616064", + "798559951534518479645224261511384773234863312866932338530531601041078616065" + ], + "pending": false, + "slug": "will-the-us-invade-iran-before-2027", + "title": "Will the U.S. invade Iran before 2027?", + "outcomes": [ + "Yes", + "No" + ], + "outcome_prices": [ + "0.155", + "0.845" + ], + "image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/will-the-us-invade-iran-in-2025-0Eh3J0ku_Fbj.jpg", + "volume": 74971240.52356401, + "tags": [ + "politics", + "iran", + "trump" + ] + }, + { + "id": "567621", + "condition_id": "0xd9fb1184af0064e5e34b129f5b79afa5a17b7e32f2953ab05efed82315fee6d4", + "position_ids": [ + "737627543131928977073597406197629922241678896546839963013331152486512197632", + "737627543131928977073597406197629922241678896546839963013331152486512197633" + ], + "pending": false, + "slug": "will-china-invade-taiwan-before-2027", + "title": "Will China invade Taiwan by end of 2026?", + "outcomes": [ + "Yes", + "No" + ], + "outcome_prices": [ + "0.0215", + "0.9785" + ], + "image": "https://polymarket-upload.s3.us-east-2.amazonaws.com/china-invades-taiwan-in-2025-CCSd9dX2mrea.jpg", + "volume": 44610248.088750005, + "tags": [ + "politics", + "china", + "geopolitics" + ] + } + ], + "next_cursor": "OQ" +} diff --git a/tests/samples/polymarket_rfq_create_buy.json b/tests/samples/polymarket_rfq_create_buy.json new file mode 100644 index 0000000..8ad9e18 --- /dev/null +++ b/tests/samples/polymarket_rfq_create_buy.json @@ -0,0 +1,32 @@ +{ + "rfq_id": "rfq_0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "status": "AWAITING_REQUESTER_ACCEPTANCE", + "expires_at": 4102444800000, + "request": { + "rfq_id": "rfq_0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "maker_address": "0x1111111111111111111111111111111111111111", + "requestor_public_id": "req_public_1", + "leg_position_ids": [ + "101", + "202" + ], + "condition_id": "0x03ababababababababababababababababababababababababababababababab", + "yes_position_id": "789", + "no_position_id": "790", + "direction": "BUY", + "side": "YES", + "requested_size": { + "unit": "notional", + "value_e6": "1000000" + }, + "created_at": 1773890758000 + }, + "quote": { + "quote_id": "quote_aaaabbbbccccddddeeeeffff00001111", + "blended_price_e6": "500000", + "maker_amount_e6": "966191", + "taker_amount_e6": "1932381", + "total_required_e6": "1000000", + "net_receive_e6": "1932381" + } +} diff --git a/tests/samples/polymarket_rfq_create_no_quotes.json b/tests/samples/polymarket_rfq_create_no_quotes.json new file mode 100644 index 0000000..ad818e5 --- /dev/null +++ b/tests/samples/polymarket_rfq_create_no_quotes.json @@ -0,0 +1,8 @@ +{ + "rfq_id": "rfq_none_1", + "status": "FAILED", + "error": { + "code": "NO_QUOTES", + "message": "no quotes" + } +} diff --git a/tests/samples/polymarket_rfq_create_sell.json b/tests/samples/polymarket_rfq_create_sell.json new file mode 100644 index 0000000..fab79e6 --- /dev/null +++ b/tests/samples/polymarket_rfq_create_sell.json @@ -0,0 +1,32 @@ +{ + "rfq_id": "rfq_sell_1", + "status": "AWAITING_REQUESTER_ACCEPTANCE", + "expires_at": 4102444800000, + "request": { + "rfq_id": "rfq_sell_1", + "maker_address": "0x1111111111111111111111111111111111111111", + "requestor_public_id": "req_public_1", + "leg_position_ids": [ + "101", + "202" + ], + "condition_id": "0x03ababababababababababababababababababababababababababababababab", + "yes_position_id": "789", + "no_position_id": "790", + "direction": "SELL", + "side": "YES", + "requested_size": { + "unit": "shares", + "value_e6": "2000000" + }, + "created_at": 1773890758000 + }, + "quote": { + "quote_id": "quote_sell_1", + "blended_price_e6": "450000", + "maker_amount_e6": "2000000", + "taker_amount_e6": "900000", + "total_required_e6": "2000000", + "net_receive_e6": "870000" + } +} diff --git a/tests/samples/polymarket_rfq_maker_execution.json b/tests/samples/polymarket_rfq_maker_execution.json new file mode 100644 index 0000000..6866c48 --- /dev/null +++ b/tests/samples/polymarket_rfq_maker_execution.json @@ -0,0 +1,9 @@ +{ + "execution": { + "execution_id": "exec_1", + "quote_id": "QUOTE", + "request": { + "rfq_id": "rfq_in_1" + } + } +} diff --git a/tests/samples/polymarket_rfq_maker_snapshot.json b/tests/samples/polymarket_rfq_maker_snapshot.json new file mode 100644 index 0000000..b63530e --- /dev/null +++ b/tests/samples/polymarket_rfq_maker_snapshot.json @@ -0,0 +1,8 @@ +{ + "request": { + "rfq_id": "rfq_in_1" + }, + "status": "COLLECTING_QUOTES", + "competition_started_at": 1780963200000, + "competition_ends_at": 1780963200400 +} diff --git a/tests/samples/polymarket_rfq_status_filled.json b/tests/samples/polymarket_rfq_status_filled.json new file mode 100644 index 0000000..90e58c7 --- /dev/null +++ b/tests/samples/polymarket_rfq_status_filled.json @@ -0,0 +1,5 @@ +{ + "rfq_id": "rfq_0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "status": "FILLED", + "tx_hash": "0xtx1" +} diff --git a/tests/samples/polymarket_rfq_status_mined.json b/tests/samples/polymarket_rfq_status_mined.json new file mode 100644 index 0000000..26987ee --- /dev/null +++ b/tests/samples/polymarket_rfq_status_mined.json @@ -0,0 +1,5 @@ +{ + "rfq_id": "rfq_0f1e2d3c4b5a69788796a5b4c3d2e1f0", + "status": "MINED", + "tx_hash": "0xtx1" +} diff --git a/tests/samples/polymarket_rfq_ws_request_buy.json b/tests/samples/polymarket_rfq_ws_request_buy.json new file mode 100644 index 0000000..39dc544 --- /dev/null +++ b/tests/samples/polymarket_rfq_ws_request_buy.json @@ -0,0 +1,19 @@ +{ + "type": "RFQ_REQUEST", + "rfq_id": "rfq_in_1", + "requestor_public_id": "req_public_2", + "leg_position_ids": [ + "101", + "202" + ], + "condition_id": "0x03ababababababababababababababababababababababababababababababab", + "yes_position_id": "789", + "no_position_id": "790", + "direction": "BUY", + "side": "YES", + "requested_size": { + "unit": "notional", + "value_e6": "1000000" + }, + "submission_deadline": 4102444800000 +} diff --git a/tests/samples/polymarket_rfq_ws_request_sell.json b/tests/samples/polymarket_rfq_ws_request_sell.json new file mode 100644 index 0000000..bc3ed92 --- /dev/null +++ b/tests/samples/polymarket_rfq_ws_request_sell.json @@ -0,0 +1,19 @@ +{ + "type": "RFQ_REQUEST", + "rfq_id": "rfq_in_2", + "requestor_public_id": "req_public_2", + "leg_position_ids": [ + "101", + "202" + ], + "condition_id": "0x03ababababababababababababababababababababababababababababababab", + "yes_position_id": "789", + "no_position_id": "790", + "direction": "SELL", + "side": "YES", + "requested_size": { + "unit": "shares", + "value_e6": "1000000" + }, + "submission_deadline": 4102444800000 +} diff --git a/tests/samples/polymarket_us_combo_board_live.json b/tests/samples/polymarket_us_combo_board_live.json new file mode 100644 index 0000000..ea68cd0 --- /dev/null +++ b/tests/samples/polymarket_us_combo_board_live.json @@ -0,0 +1,772 @@ +{ + "source": "GET gateway.polymarket.us/v1/events?active=true&closed=false&orderBy=id&orderDirection=desc (public); read 2026-10-22, trimmed", + "events": [ + { + "id": "178416", + "slug": "setkamecz-akoart-mulpat-2026-10-22", + "title": "Akopyan Artur vs. Muller Patrik", + "startTime": "2026-10-22T13:05:00Z", + "seriesSlug": "setka-cup-czechia-men-2026", + "category": "sports", + "tags": [ + { + "id": "2", + "label": "Sports", + "slug": "sports" + }, + { + "id": "3", + "label": "Games", + "slug": "games" + }, + { + "id": "655", + "label": "Table Tennis", + "slug": "table-tennis" + }, + { + "id": "721", + "label": "Setka Cup Czechia Men", + "slug": "setkamecz" + }, + { + "id": "726", + "label": "Setka", + "slug": "setka" + } + ], + "teams": [ + { + "id": 19814, + "name": "Akopyan Artur", + "abbreviation": "akoart", + "displayAbbreviation": "Artur", + "alias": "Akopyan Artur" + }, + { + "id": 31434, + "name": "M\u00fcller Patrik", + "abbreviation": "mulpat", + "displayAbbreviation": "Patrik", + "alias": "M\u00fcller Patrik" + } + ], + "markets": [ + { + "id": "1679301", + "slug": "aec-setkamecz-akoart-mulpat-2026-10-22", + "question": "Who will win in the upcoming table-tennis event Akopyan Artur vs Muller Patrik scheduled for October 22, 2026 at 1:05 PM UTC?", + "title": "Akopyan Artur vs M\u00fcller Patrik", + "titleShort": "Artur vs Patrik", + "marketType": "moneyline", + "sportsMarketType": "table_tennis_match_winner", + "sportsMarketTypeV2": "SPORTS_MARKET_TYPE_MONEYLINE", + "outcomes": "[\"Akopyan Artur\",\"M\u00fcller Patrik\"]", + "outcomePrices": "[\"0.5100\",\"0.5500\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-22T13:05:00Z", + "status": "MARKET_STATUS_OPEN", + "bestBidQuote": { + "value": "0.5100", + "currency": "USD" + }, + "bestAskQuote": { + "value": "0.5500", + "currency": "USD" + }, + "active": true, + "closed": false, + "marketSides": [ + { + "description": "Akopyan Artur", + "long": true, + "team": { + "id": 19814, + "name": "Akopyan Artur", + "abbreviation": "akoart", + "displayAbbreviation": "Artur", + "alias": "Akopyan Artur", + "ordering": "home" + } + }, + { + "description": "M\u00fcller Patrik", + "long": false, + "team": { + "id": 31434, + "name": "M\u00fcller Patrik", + "abbreviation": "mulpat", + "displayAbbreviation": "Patrik", + "alias": "M\u00fcller Patrik", + "ordering": "away" + } + } + ] + } + ] + }, + { + "id": "178368", + "slug": "dota2-nemiga-icxi-2026-10-25", + "title": "Nemiga Gaming vs. Inner Circle x Insanity Game 2", + "startTime": "2026-10-25T12:00:00Z", + "seriesSlug": "dota-2", + "category": "sports", + "tags": [ + { + "id": "2", + "label": "Sports", + "slug": "sports" + }, + { + "id": "39", + "label": "Esports", + "slug": "esports" + }, + { + "id": "258", + "label": "Dota 2", + "slug": "dota2" + } + ], + "teams": [ + { + "id": 7786, + "name": "Nemiga Gaming", + "abbreviation": "nemiga", + "displayAbbreviation": "NEMIGA", + "alias": "" + }, + { + "id": 9302, + "name": "Inner Circle x Insanity", + "abbreviation": "icxi", + "displayAbbreviation": "ICXI", + "alias": "" + } + ], + "markets": [ + { + "id": "1679182", + "slug": "aec-dota2-nemiga-icxi-2026-10-25", + "question": "Who will win in the upcoming esports event Nemiga Gaming vs Inner Circle x Insanity scheduled for October 25, 2026 at 12:00 PM UTC?", + "title": "Nemiga Gaming vs Inner Circle x Insanity", + "titleShort": "NEMIGA vs ICXI", + "marketType": "moneyline", + "sportsMarketType": "esports_match_winner", + "sportsMarketTypeV2": "SPORTS_MARKET_TYPE_MONEYLINE", + "outcomes": "[\"Nemiga Gaming\",\"Inner Circle x Insanity\"]", + "outcomePrices": "[\"0.0200\",\"0.9800\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-25T12:00:00Z", + "status": "MARKET_STATUS_OPEN", + "active": true, + "closed": false, + "marketSides": [ + { + "description": "Nemiga Gaming", + "long": true, + "team": { + "id": 7786, + "name": "Nemiga Gaming", + "abbreviation": "nemiga", + "displayAbbreviation": "NEMIGA", + "alias": "", + "ordering": "home" + } + }, + { + "description": "Inner Circle x Insanity", + "long": false, + "team": { + "id": 9302, + "name": "Inner Circle x Insanity", + "abbreviation": "icxi", + "displayAbbreviation": "ICXI", + "alias": "", + "ordering": "away" + } + } + ] + } + ] + }, + { + "id": "178333", + "slug": "cba-sdk-tia-2026-10-25", + "title": "Shandong Hi-Speed Kirin vs. Tianjin Pioneers", + "startTime": "2026-10-25T11:35:00Z", + "seriesSlug": "cba-2026", + "category": "sports", + "tags": [ + { + "id": "2", + "label": "Sports", + "slug": "sports" + }, + { + "id": "3", + "label": "Games", + "slug": "games" + }, + { + "id": "11", + "label": "Basketball", + "slug": "basketball" + }, + { + "id": "612", + "label": "International Basketball", + "slug": "international-basketball" + }, + { + "id": "1494", + "label": "China", + "slug": "china" + }, + { + "id": "1495", + "label": "CBA", + "slug": "cba" + } + ], + "teams": [ + { + "id": 36492, + "name": "Shandong Hi-Speed Kirin", + "abbreviation": "sdk", + "displayAbbreviation": "SDK", + "alias": "" + }, + { + "id": 36502, + "name": "Tianjin Pioneers", + "abbreviation": "tia", + "displayAbbreviation": "TIA", + "alias": "" + } + ], + "markets": [ + { + "id": "1677885", + "slug": "aec-cba-sdk-tia-2026-10-25", + "question": "Who will win in the upcoming basketball event Shandong Hi-Speed Kirin vs Tianjin Pioneers scheduled for October 25, 2026 at 11:35 AM UTC?", + "title": "Shandong Hi-Speed Kirin vs Tianjin Pioneers", + "titleShort": "SDK vs TIA", + "marketType": "moneyline", + "sportsMarketType": "basketball_team_full_game_winner", + "sportsMarketTypeV2": "SPORTS_MARKET_TYPE_MONEYLINE", + "outcomes": "[\"Shandong Hi-Speed Kirin\",\"Tianjin Pioneers\"]", + "outcomePrices": "[\"0.1000\",\"0.9000\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-25T11:35:00Z", + "status": "MARKET_STATUS_OPEN", + "active": true, + "closed": false, + "marketSides": [ + { + "description": "Shandong Hi-Speed Kirin", + "long": true, + "team": { + "id": 36492, + "name": "Shandong Hi-Speed Kirin", + "abbreviation": "sdk", + "displayAbbreviation": "SDK", + "alias": "", + "ordering": "home" + } + }, + { + "description": "Tianjin Pioneers", + "long": false, + "team": { + "id": 36502, + "name": "Tianjin Pioneers", + "abbreviation": "tia", + "displayAbbreviation": "TIA", + "alias": "", + "ordering": "away" + } + } + ] + } + ] + }, + { + "id": "178113", + "slug": "khl-amr-adm-2026-10-25", + "title": "Amur Khabarovsk vs. Admiral Vladivostok", + "startTime": "2026-10-25T07:00:00Z", + "seriesSlug": "khl-2026", + "category": "sports", + "tags": [ + { + "id": "2", + "label": "Sports", + "slug": "sports" + }, + { + "id": "3", + "label": "Games", + "slug": "games" + }, + { + "id": "14", + "label": "Hockey", + "slug": "ice-hockey" + }, + { + "id": "1356", + "label": "International Hockey", + "slug": "international-hockey" + }, + { + "id": "1357", + "label": "KHL", + "slug": "khl" + } + ], + "teams": [ + { + "id": 33998, + "name": "Amur Khabarovsk", + "abbreviation": "amr", + "displayAbbreviation": "AMR", + "alias": "" + }, + { + "id": 34001, + "name": "Admiral Vladivostok", + "abbreviation": "adm", + "displayAbbreviation": "ADM", + "alias": "" + } + ], + "markets": [ + { + "id": "1674964", + "slug": "aec-khl-amr-adm-2026-10-25", + "question": "Who will win in the upcoming hockey event Amur Khabarovsk vs Admiral Vladivostok scheduled for October 25, 2026 at 7:00 AM UTC?", + "title": "Amur Khabarovsk vs Admiral Vladivostok", + "titleShort": "AMR vs ADM", + "marketType": "moneyline", + "sportsMarketType": "hockey_team_full_game_winner", + "sportsMarketTypeV2": "SPORTS_MARKET_TYPE_MONEYLINE", + "outcomes": "[\"Amur Khabarovsk\",\"Admiral Vladivostok\"]", + "outcomePrices": "[\"0.5800\",\"0.6400\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-25T07:00:00Z", + "status": "MARKET_STATUS_OPEN", + "bestBidQuote": { + "value": "0.5800", + "currency": "USD" + }, + "bestAskQuote": { + "value": "0.6400", + "currency": "USD" + }, + "active": true, + "closed": false, + "marketSides": [ + { + "description": "Amur Khabarovsk", + "long": true, + "team": { + "id": 33998, + "name": "Amur Khabarovsk", + "abbreviation": "amr", + "displayAbbreviation": "AMR", + "alias": "", + "ordering": "home" + } + }, + { + "description": "Admiral Vladivostok", + "long": false, + "team": { + "id": 34001, + "name": "Admiral Vladivostok", + "abbreviation": "adm", + "displayAbbreviation": "ADM", + "alias": "", + "ordering": "away" + } + } + ] + } + ] + }, + { + "id": "177819", + "slug": "lco-tol-adp-2026-10-25", + "title": "CD Tolima vs. AD Pasto", + "startTime": "2026-10-26T01:15:00Z", + "seriesSlug": "lco", + "category": "sports", + "tags": [ + { + "id": "2", + "label": "Sports", + "slug": "sports" + }, + { + "id": "12", + "label": "Soccer", + "slug": "soccer" + }, + { + "id": "675", + "label": "Liga Colombia", + "slug": "lco" + } + ], + "teams": [ + { + "id": 17077, + "name": "CD Tolima", + "abbreviation": "tol", + "displayAbbreviation": "TOL", + "alias": "" + }, + { + "id": 17075, + "name": "AD Pasto", + "abbreviation": "adp", + "displayAbbreviation": "ADP", + "alias": "" + } + ], + "markets": [ + { + "id": "1668037", + "slug": "atc-lco-tol-adp-2026-10-25-tol", + "question": "Will CD Tolima win against AD Pasto in the Liga Colombia match scheduled for Oct 25, 2026?", + "title": "CD Tolima (Reg. Time)", + "titleShort": "TOL (Reg. Time)", + "marketType": "drawable_outcome", + "sportsMarketType": "soccer_team_full_time_winner", + "sportsMarketTypeV2": "SPORTS_MARKET_TYPE_DRAWABLE_OUTCOME", + "outcomes": "[\"Yes\",\"No\"]", + "outcomePrices": "[\"0.6000\",\"0.6800\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-26T01:15:00Z", + "status": "MARKET_STATUS_OPEN", + "bestBidQuote": { + "value": "0.6000", + "currency": "USD" + }, + "bestAskQuote": { + "value": "0.6800", + "currency": "USD" + }, + "active": true, + "closed": false, + "marketSides": [ + { + "description": "Yes", + "long": true, + "team": { + "id": 17077, + "name": "CD Tolima", + "abbreviation": "tol", + "displayAbbreviation": "TOL", + "alias": "", + "ordering": "home" + } + }, + { + "description": "No", + "long": false, + "team": { + "id": 17077, + "name": "CD Tolima", + "abbreviation": "tol", + "displayAbbreviation": "TOL", + "alias": "", + "ordering": "away" + } + } + ] + }, + { + "id": "1668038", + "slug": "atc-lco-tol-adp-2026-10-25-draw", + "question": "Will the Liga Colombia match CD Tolima vs AD Pasto scheduled for Oct 25, 2026 end in a draw?", + "title": "Tie (Reg. Time)", + "titleShort": "Tie (Reg. Time)", + "marketType": "drawable_outcome", + "sportsMarketType": "soccer_team_full_time_winner", + "sportsMarketTypeV2": "SPORTS_MARKET_TYPE_DRAWABLE_OUTCOME", + "outcomes": "[\"Yes\",\"No\"]", + "outcomePrices": "[\"0.1800\",\"0.2500\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-26T01:15:00Z", + "status": "MARKET_STATUS_OPEN", + "bestBidQuote": { + "value": "0.1800", + "currency": "USD" + }, + "bestAskQuote": { + "value": "0.2500", + "currency": "USD" + }, + "active": true, + "closed": false, + "marketSides": [ + { + "description": "Yes", + "long": true, + "team": null + }, + { + "description": "No", + "long": false, + "team": null + } + ] + }, + { + "id": "1668040", + "slug": "atc-lco-tol-adp-2026-10-25-adp", + "question": "Will AD Pasto win against CD Tolima in the Liga Colombia match scheduled for Oct 25, 2026?", + "title": "AD Pasto (Reg. Time)", + "titleShort": "ADP (Reg. Time)", + "marketType": "drawable_outcome", + "sportsMarketType": "soccer_team_full_time_winner", + "sportsMarketTypeV2": "SPORTS_MARKET_TYPE_DRAWABLE_OUTCOME", + "outcomes": "[\"Yes\",\"No\"]", + "outcomePrices": "[\"0.0800\",\"0.1500\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-26T01:15:00Z", + "status": "MARKET_STATUS_OPEN", + "bestBidQuote": { + "value": "0.0800", + "currency": "USD" + }, + "bestAskQuote": { + "value": "0.1500", + "currency": "USD" + }, + "active": true, + "closed": false, + "marketSides": [ + { + "description": "Yes", + "long": true, + "team": { + "id": 17075, + "name": "AD Pasto", + "abbreviation": "adp", + "displayAbbreviation": "ADP", + "alias": "", + "ordering": "home" + } + }, + { + "description": "No", + "long": false, + "team": { + "id": 17075, + "name": "AD Pasto", + "abbreviation": "adp", + "displayAbbreviation": "ADP", + "alias": "", + "ordering": "away" + } + } + ] + } + ] + }, + { + "id": "178310", + "slug": "t20icr-nze-india-2026-10-24", + "title": "New Zealand vs. India", + "startTime": "2026-10-24T07:00:00Z", + "seriesSlug": "t20i-cr-2026", + "category": "sports", + "tags": [ + { + "id": "2", + "label": "Sports", + "slug": "sports" + }, + { + "id": "3", + "label": "Games", + "slug": "games" + }, + { + "id": "36", + "label": "Cricket", + "slug": "cricket" + }, + { + "id": "696", + "label": "T20 Intl", + "slug": "t20icr" + } + ], + "teams": [ + { + "id": 41777, + "name": "New Zealand", + "abbreviation": "nze", + "displayAbbreviation": "NZE", + "alias": "New Zealand" + }, + { + "id": 20953, + "name": "India", + "abbreviation": "india", + "displayAbbreviation": "INDIA", + "alias": "India" + } + ], + "markets": [ + { + "id": "1677809", + "slug": "aec-t20icr-nze-india-2026-10-24", + "question": "Who will win in the upcoming cricket event New Zealand vs India scheduled for October 24, 2026 at 7:00 AM UTC?", + "title": "New Zealand vs India", + "titleShort": "NZE vs INDIA", + "marketType": "moneyline", + "sportsMarketType": "cricket_match_winner", + "sportsMarketTypeV2": "SPORTS_MARKET_TYPE_MONEYLINE", + "outcomes": "[\"New Zealand\",\"India\"]", + "outcomePrices": "[\"0.1000\",\"0.5200\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-24T07:00:00Z", + "status": "MARKET_STATUS_OPEN", + "bestBidQuote": { + "value": "0.1000", + "currency": "USD" + }, + "bestAskQuote": { + "value": "0.5200", + "currency": "USD" + }, + "active": true, + "closed": false, + "marketSides": [ + { + "description": "New Zealand", + "long": true, + "team": { + "id": 41777, + "name": "New Zealand", + "abbreviation": "nze", + "displayAbbreviation": "NZE", + "alias": "New Zealand", + "ordering": "home" + } + }, + { + "description": "India", + "long": false, + "team": { + "id": 20953, + "name": "India", + "abbreviation": "india", + "displayAbbreviation": "INDIA", + "alias": "India", + "ordering": "away" + } + } + ] + } + ] + }, + { + "id": "178257", + "slug": "npb-ybo-fsh-2026-10-25", + "title": "Yokohama BayStars vs. Fukuoka SoftBank Hawks", + "startTime": "2026-10-25T09:00:00Z", + "seriesSlug": "npb-2026", + "category": "sports", + "tags": [ + { + "id": "2", + "label": "Sports", + "slug": "sports" + }, + { + "id": "3", + "label": "Games", + "slug": "games" + }, + { + "id": "15", + "label": "Baseball", + "slug": "baseball" + }, + { + "id": "544", + "label": "NPB", + "slug": "npb" + } + ], + "teams": [ + { + "id": 11388, + "name": "Yokohama BayStars", + "abbreviation": "ybo", + "displayAbbreviation": "YBO", + "alias": "" + }, + { + "id": 11384, + "name": "Fukuoka SoftBank Hawks", + "abbreviation": "fsh", + "displayAbbreviation": "FSH", + "alias": "" + } + ], + "markets": [ + { + "id": "1676661", + "slug": "aec-npb-ybo-fsh-2026-10-25", + "question": "Who will win in the upcoming baseball event Yokohama BayStars vs Fukuoka SoftBank Hawks scheduled for October 25, 2026 at 9:00 AM UTC?", + "title": "Yokohama BayStars vs Fukuoka SoftBank Hawks", + "titleShort": "YBO vs FSH", + "marketType": "moneyline", + "sportsMarketType": "baseball_team_full_game_winner", + "sportsMarketTypeV2": "SPORTS_MARKET_TYPE_MONEYLINE", + "outcomes": "[\"Yokohama BayStars\",\"Fukuoka SoftBank Hawks\"]", + "outcomePrices": "[\"0.1000\",\"0.8900\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-25T09:00:00Z", + "status": "MARKET_STATUS_OPEN", + "active": true, + "closed": false, + "marketSides": [ + { + "description": "Yokohama BayStars", + "long": true, + "team": { + "id": 11388, + "name": "Yokohama BayStars", + "abbreviation": "ybo", + "displayAbbreviation": "YBO", + "alias": "", + "ordering": "home" + } + }, + { + "description": "Fukuoka SoftBank Hawks", + "long": false, + "team": { + "id": 11384, + "name": "Fukuoka SoftBank Hawks", + "abbreviation": "fsh", + "displayAbbreviation": "FSH", + "alias": "", + "ordering": "away" + } + } + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/samples/polymarket_us_combo_events.json b/tests/samples/polymarket_us_combo_events.json new file mode 100644 index 0000000..b4e4be5 --- /dev/null +++ b/tests/samples/polymarket_us_combo_events.json @@ -0,0 +1,185 @@ +{ + "events": [ + { + "id": "167436", + "ticker": "lal-fcb-rma-2026-10-25", + "slug": "lal-fcb-rma-2026-10-25", + "title": "FC Barcelona vs. Real Madrid CF", + "startDate": "2026-10-25T20:00:00Z", + "endDate": "2026-10-26T03:59:00Z", + "active": true, + "closed": false, + "archived": false, + "category": "sports", + "seriesSlug": "lal-2025", + "gameId": 90117211, + "startTime": "2026-10-25T20:00:00Z", + "markets": [ + { + "id": "1532970", + "question": "Will FC Barcelona win against Real Madrid CF in the La Liga match scheduled for Oct 25, 2026?", + "slug": "atc-lal-fcb-rma-2026-10-25-fcb", + "category": "sports", + "active": true, + "closed": true, + "marketType": "drawable_outcome", + "sportsMarketType": "soccer_team_full_time_winner", + "status": "MARKET_STATUS_HALTED", + "orderPriceMinTickSize": 0.01, + "outcomes": "[\"Yes\",\"No\"]", + "outcomePrices": "[\"0\",\"0\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-25T20:00:00Z" + }, + { + "id": "1532971", + "question": "Will the La Liga match FC Barcelona vs Real Madrid CF scheduled for Oct 25, 2026 end in a draw?", + "slug": "atc-lal-fcb-rma-2026-10-25-draw", + "category": "sports", + "active": true, + "closed": true, + "marketType": "drawable_outcome", + "sportsMarketType": "soccer_team_full_time_winner", + "status": "MARKET_STATUS_HALTED", + "orderPriceMinTickSize": 0.01, + "outcomes": "[\"Yes\",\"No\"]", + "outcomePrices": "[\"0\",\"0\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-25T20:00:00Z" + } + ], + "tags": [ + { + "id": "2", + "label": "Sports", + "slug": "sports" + }, + { + "id": "12", + "label": "Soccer", + "slug": "soccer" + }, + { + "id": "27", + "label": "La Liga", + "slug": "lal" + }, + { + "id": "1833", + "label": "El Cl\u00e1sico", + "slug": "el-clasico" + } + ] + }, + { + "id": "148649", + "ticker": "nfl-ne-chi-2026-10-22", + "slug": "nfl-ne-chi-2026-10-22", + "title": "NE Patriots vs. CHI Bears", + "startDate": "2026-10-23T00:15:00Z", + "endDate": "2026-10-23T03:59:00Z", + "active": true, + "closed": false, + "archived": false, + "category": "sports", + "seriesSlug": "nfl-2025", + "gameId": 19548, + "startTime": "2026-10-23T00:15:00Z", + "markets": [ + { + "id": "1288608", + "question": "Who will win in the upcoming football event New England Patriots vs Chicago Bears scheduled for October 23, 2026 at 12:15 AM UTC?", + "slug": "aec-nfl-ne-chi-2026-10-22", + "category": "sports", + "active": true, + "closed": true, + "marketType": "moneyline", + "sportsMarketType": "football_team_full_game_winner", + "status": "MARKET_STATUS_HALTED", + "orderPriceMinTickSize": 0.0025, + "outcomes": "[\"Patriots\",\"Bears\"]", + "outcomePrices": "[\"0\",\"0\"]", + "comboEnabled": true, + "gameStartTime": "2026-10-23T00:15:00Z" + } + ], + "tags": [ + { + "id": "1", + "label": "NFL", + "slug": "nfl" + }, + { + "id": "2", + "label": "Sports", + "slug": "sports" + }, + { + "id": "3", + "label": "Games", + "slug": "games" + }, + { + "id": "13", + "label": "Football", + "slug": "football" + }, + { + "id": "1790", + "label": "Week 7", + "slug": "week-7" + } + ] + }, + { + "id": "8349", + "ticker": "usho-midterms-2026-11-03", + "slug": "usho-midterms-2026-11-03", + "title": "U.S House Midterm Winner", + "startDate": "2026-11-03T00:00:00Z", + "endDate": "2026-11-04T04:59:00Z", + "active": true, + "closed": false, + "archived": false, + "category": "politics", + "seriesSlug": "us-midterms-2026", + "gameId": null, + "startTime": "2026-11-03T00:00:00Z", + "markets": [ + { + "id": "11325", + "question": "U.S House Midterm Winner", + "slug": "paccc-usho-midterms-2026-11-03-dem", + "category": "politics", + "active": true, + "closed": false, + "marketType": "election", + "sportsMarketType": "election", + "status": "MARKET_STATUS_OPEN", + "orderPriceMinTickSize": 0.001, + "outcomes": "[\"Yes\",\"No\"]", + "outcomePrices": "[\"0.9080\",\"0.9090\"]", + "comboEnabled": false, + "gameStartTime": "2026-11-03T00:00:00Z" + } + ], + "tags": [ + { + "id": "30", + "label": "Politics", + "slug": "politics" + }, + { + "id": "31", + "label": "Midterms", + "slug": "midterms" + }, + { + "id": "1783", + "label": "US Politics", + "slug": "us-politics" + } + ] + } + ] +} diff --git a/tests/samples/polymarket_us_rfq_create_combo.json b/tests/samples/polymarket_us_rfq_create_combo.json new file mode 100644 index 0000000..4e5583b --- /dev/null +++ b/tests/samples/polymarket_us_rfq_create_combo.json @@ -0,0 +1,18 @@ +{ + "combo": { + "id": "caoc-example", + "legs": [ + { + "symbol": "tec-mlb-nlchamp-2026-09-27-atl", + "side": "SIDE_BUY" + }, + { + "symbol": "tec-mlb-alchamp-2026-09-27-nyy", + "side": "SIDE_SELL" + } + ], + "state": "INSTRUMENT_STATE_OPEN", + "createdTime": "2026-07-29T14:00:00Z", + "tickSize": 0.001 + } +} diff --git a/tests/samples/polymarket_us_rfq_create_quote.json b/tests/samples/polymarket_us_rfq_create_quote.json new file mode 100644 index 0000000..4a145ba --- /dev/null +++ b/tests/samples/polymarket_us_rfq_create_quote.json @@ -0,0 +1,3 @@ +{ + "quoteId": "quote_09" +} diff --git a/tests/samples/polymarket_us_rfq_create_rfq.json b/tests/samples/polymarket_us_rfq_create_rfq.json new file mode 100644 index 0000000..79a6b9d --- /dev/null +++ b/tests/samples/polymarket_us_rfq_create_rfq.json @@ -0,0 +1,3 @@ +{ + "rfqId": "rfq_01" +} diff --git a/tests/samples/polymarket_us_rfq_empty.json b/tests/samples/polymarket_us_rfq_empty.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/tests/samples/polymarket_us_rfq_empty.json @@ -0,0 +1 @@ +{} diff --git a/tests/samples/polymarket_us_rfq_get_combos.json b/tests/samples/polymarket_us_rfq_get_combos.json new file mode 100644 index 0000000..f8afec6 --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_combos.json @@ -0,0 +1,20 @@ +{ + "combos": [ + { + "id": "caoc-example", + "legs": [ + { + "symbol": "tec-mlb-nlchamp-2026-09-27-atl", + "side": "SIDE_BUY" + }, + { + "symbol": "tec-mlb-alchamp-2026-09-27-nyy", + "side": "SIDE_SELL" + } + ], + "state": "INSTRUMENT_STATE_OPEN", + "createdTime": "2026-07-29T14:00:00Z", + "tickSize": 0.001 + } + ] +} diff --git a/tests/samples/polymarket_us_rfq_get_combos_empty.json b/tests/samples/polymarket_us_rfq_get_combos_empty.json new file mode 100644 index 0000000..8da2293 --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_combos_empty.json @@ -0,0 +1,3 @@ +{ + "combos": [] +} diff --git a/tests/samples/polymarket_us_rfq_get_quotes.json b/tests/samples/polymarket_us_rfq_get_quotes.json new file mode 100644 index 0000000..361b3ba --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_quotes.json @@ -0,0 +1,39 @@ +{ + "quotes": [ + { + "id": "quote_01", + "rfqId": "rfq_01", + "creatorRfqUserId": "rfquser_maker", + "symbol": "caoc-example", + "status": "QUOTE_STATUS_ACTIVE", + "createdTime": "2026-07-29T14:00:00.200Z", + "buyPrice": "0.615", + "sellPrice": "0.585", + "restRemainder": false, + "postOnly": true, + "rfqCreatorUserId": "rfquser_me", + "rfqCashOrderQty": "10.0000", + "buyQtyDecimal": "16.26", + "sellQtyDecimal": "24.09", + "updatedTime": "2026-07-29T14:00:00.200Z" + }, + { + "id": "quote_02", + "rfqId": "rfq_01", + "creatorRfqUserId": "rfquser_maker", + "symbol": "caoc-example", + "status": "QUOTE_STATUS_ACTIVE", + "createdTime": "2026-07-29T14:00:00.200Z", + "buyPrice": "0.62", + "sellPrice": "0", + "restRemainder": false, + "postOnly": true, + "rfqCreatorUserId": "rfquser_me", + "rfqCashOrderQty": "10.0000", + "buyQtyDecimal": "16.12", + "sellQtyDecimal": "0", + "updatedTime": "2026-07-29T14:00:00.200Z" + } + ], + "cursor": "" +} diff --git a/tests/samples/polymarket_us_rfq_get_quotes_accepted.json b/tests/samples/polymarket_us_rfq_get_quotes_accepted.json new file mode 100644 index 0000000..72e4293 --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_quotes_accepted.json @@ -0,0 +1,25 @@ +{ + "quotes": [ + { + "id": "quote_01", + "rfqId": "rfq_01", + "creatorRfqUserId": "rfquser_maker", + "symbol": "caoc-example", + "status": "QUOTE_STATUS_ACCEPTED", + "createdTime": "2026-07-29T14:00:00.200Z", + "buyPrice": "0.615", + "sellPrice": "0.585", + "restRemainder": false, + "postOnly": true, + "rfqCreatorUserId": "rfquser_me", + "rfqCashOrderQty": "10.0000", + "buyQtyDecimal": "16.26", + "sellQtyDecimal": "24.09", + "updatedTime": "2026-07-29T14:00:01Z", + "acceptedSide": "SIDE_BUY", + "acceptedTime": "2026-07-29T14:00:01Z", + "confirmationDeadline": "2026-07-29T14:00:04Z" + } + ], + "cursor": "" +} diff --git a/tests/samples/polymarket_us_rfq_get_quotes_deleted.json b/tests/samples/polymarket_us_rfq_get_quotes_deleted.json new file mode 100644 index 0000000..d0433eb --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_quotes_deleted.json @@ -0,0 +1,22 @@ +{ + "quotes": [ + { + "id": "quote_01", + "rfqId": "rfq_01", + "creatorRfqUserId": "rfquser_maker", + "symbol": "caoc-example", + "status": "QUOTE_STATUS_DELETED", + "createdTime": "2026-07-29T14:00:00.200Z", + "buyPrice": "0.615", + "sellPrice": "0.585", + "restRemainder": false, + "postOnly": true, + "rfqCreatorUserId": "rfquser_me", + "rfqCashOrderQty": "10.0000", + "buyQtyDecimal": "16.26", + "sellQtyDecimal": "24.09", + "updatedTime": "2026-07-29T14:00:05Z" + } + ], + "cursor": "" +} diff --git a/tests/samples/polymarket_us_rfq_get_quotes_executed.json b/tests/samples/polymarket_us_rfq_get_quotes_executed.json new file mode 100644 index 0000000..5bb7af4 --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_quotes_executed.json @@ -0,0 +1,30 @@ +{ + "quotes": [ + { + "id": "quote_01", + "rfqId": "rfq_01", + "creatorRfqUserId": "rfquser_maker", + "symbol": "caoc-example", + "status": "QUOTE_STATUS_EXECUTED", + "createdTime": "2026-07-29T14:00:00.200Z", + "buyPrice": "0.615", + "sellPrice": "0.585", + "restRemainder": false, + "postOnly": true, + "rfqCreatorUserId": "rfquser_me", + "rfqCashOrderQty": "10.0000", + "buyQtyDecimal": "16.26", + "sellQtyDecimal": "24.09", + "updatedTime": "2026-07-29T14:00:03.050Z", + "acceptedSide": "SIDE_BUY", + "acceptedTime": "2026-07-29T14:00:01Z", + "confirmationDeadline": "2026-07-29T14:00:04Z", + "confirmedTime": "2026-07-29T14:00:02Z", + "executionDeadline": "2026-07-29T14:00:03Z", + "executedTime": "2026-07-29T14:00:03.050Z", + "rfqCreatorOrderId": "ord_requester_01", + "creatorOrderId": "ord_maker_01" + } + ], + "cursor": "" +} diff --git a/tests/samples/polymarket_us_rfq_get_quotes_mine.json b/tests/samples/polymarket_us_rfq_get_quotes_mine.json new file mode 100644 index 0000000..c96c5cd --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_quotes_mine.json @@ -0,0 +1,22 @@ +{ + "quotes": [ + { + "id": "quote_09", + "rfqId": "rfq_02", + "creatorRfqUserId": "rfquser_me", + "symbol": "crossd-example", + "status": "QUOTE_STATUS_ACTIVE", + "createdTime": "2026-07-29T14:00:00.200Z", + "buyPrice": "0.615", + "sellPrice": "0.585", + "restRemainder": false, + "postOnly": true, + "rfqCreatorUserId": "rfquser_other", + "rfqCashOrderQty": null, + "buyQtyDecimal": "25", + "sellQtyDecimal": "25", + "updatedTime": "2026-07-29T14:00:00.200Z" + } + ], + "cursor": "" +} diff --git a/tests/samples/polymarket_us_rfq_get_quotes_mine_accepted.json b/tests/samples/polymarket_us_rfq_get_quotes_mine_accepted.json new file mode 100644 index 0000000..cc47679 --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_quotes_mine_accepted.json @@ -0,0 +1,25 @@ +{ + "quotes": [ + { + "id": "quote_09", + "rfqId": "rfq_02", + "creatorRfqUserId": "rfquser_me", + "symbol": "crossd-example", + "status": "QUOTE_STATUS_ACCEPTED", + "createdTime": "2026-07-29T14:00:00.200Z", + "buyPrice": "0.615", + "sellPrice": "0.585", + "restRemainder": false, + "postOnly": true, + "rfqCreatorUserId": "rfquser_other", + "rfqCashOrderQty": null, + "buyQtyDecimal": "25", + "sellQtyDecimal": "25", + "updatedTime": "2026-07-29T14:00:00.200Z", + "acceptedSide": "SIDE_SELL", + "acceptedTime": "2026-07-29T14:01:01Z", + "confirmationDeadline": "2026-07-29T14:01:04Z" + } + ], + "cursor": "" +} diff --git a/tests/samples/polymarket_us_rfq_get_rfqs.json b/tests/samples/polymarket_us_rfq_get_rfqs.json new file mode 100644 index 0000000..32e06bc --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_rfqs.json @@ -0,0 +1,38 @@ +{ + "rfqs": [ + { + "id": "rfq_01", + "cashOrderQty": "10.0000", + "symbol": "caoc-example", + "rfqCreatorUserId": "rfquser_me", + "createdTime": "2026-07-29T14:00:00Z", + "restRemainder": false, + "status": "RFQ_STATUS_OPEN", + "updatedTime": "2026-07-29T14:00:00Z", + "comboLegs": [ + { + "symbol": "tec-mlb-nlchamp-2026-09-27-atl", + "side": "SIDE_BUY", + "settlementPrice": "0.4" + }, + { + "symbol": "tec-mlb-alchamp-2026-09-27-nyy", + "side": "SIDE_SELL", + "settlementPrice": "0" + } + ], + "tickSize": 0.001 + }, + { + "id": "rfq_02", + "qtyDecimal": "25", + "symbol": "crossd-example", + "rfqCreatorUserId": "rfquser_other", + "createdTime": "2026-07-29T14:01:00Z", + "restRemainder": true, + "status": "RFQ_STATUS_OPEN", + "updatedTime": "2026-07-29T14:01:00Z" + } + ], + "cursor": "" +} diff --git a/tests/samples/polymarket_us_rfq_get_rfqs_empty.json b/tests/samples/polymarket_us_rfq_get_rfqs_empty.json new file mode 100644 index 0000000..f15a43c --- /dev/null +++ b/tests/samples/polymarket_us_rfq_get_rfqs_empty.json @@ -0,0 +1,4 @@ +{ + "rfqs": [], + "cursor": "" +} diff --git a/tests/samples/polymarket_us_rfq_user_id.json b/tests/samples/polymarket_us_rfq_user_id.json new file mode 100644 index 0000000..9bbf6e3 --- /dev/null +++ b/tests/samples/polymarket_us_rfq_user_id.json @@ -0,0 +1,3 @@ +{ + "rfqUserId": "rfquser_me" +} diff --git a/tests/samples/polymarket_v3_signing_vectors.json b/tests/samples/polymarket_v3_signing_vectors.json new file mode 100644 index 0000000..671704a --- /dev/null +++ b/tests/samples/polymarket_v3_signing_vectors.json @@ -0,0 +1,99 @@ +{ + "source": "Independent of synpath: each order's typed data written out as docs.polymarket.com prints it (/trading/combos/requesters, /trading/combos/market-makers: exchangeV3OrderTypedData and depositWalletTypedData), signed by eth_account 0.13 with the public Hardhat key, and the Deposit Wallet signature wrapped by the docs' wrapDepositWalletSignature algorithm re-written with eth_abi. The venue publishes no Exchange v3 vectors (py-sdk's golden tests cover CLOB V2 only).", + "private_key": "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "address": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "wallet": "0x1111111111111111111111111111111111111111", + "exchange_v3": "0xe3333700cA9d93003F00f0F71f8515005F6c00Aa", + "orders": [ + { + "name": "deposit_buy", + "order": { + "salt": "12345", + "maker": "0x1111111111111111111111111111111111111111", + "signer": "0x1111111111111111111111111111111111111111", + "tokenId": "789", + "makerAmount": "966191", + "takerAmount": "1932381", + "side": 0, + "signatureType": 3, + "timestamp": "1773890760", + "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000", + "builder": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "order_hash": "0xbe164889860e1eb75856a15390b90024ab16f9d0bf6cef47a639aa0ea1c71f68", + "signature": "0x5e15b509bbfb9428bbf96d95b73f5b3881491eae632d166678160630fee01583108c630e679a3d8ec835117d33c64620329307581112b36c23d5a87d48af831e1b466c63910185bbd55e8679264200c4e0abdcbb0c6264eb3d41d13326022e095b70174be8b3cb5356cc65f5f90483de7549abad6f03e71ccaec95c52fa773c2e84f726465722875696e743235362073616c742c61646472657373206d616b65722c61646472657373207369676e65722c75696e7432353620746f6b656e49642c75696e74323536206d616b6572416d6f756e742c75696e743235362074616b6572416d6f756e742c75696e743820736964652c75696e7438207369676e6174757265547970652c75696e743235362074696d657374616d702c62797465733332206d657461646174612c62797465733332206275696c6465722900ba" + }, + { + "name": "deposit_sell", + "order": { + "salt": "987654321", + "maker": "0x1111111111111111111111111111111111111111", + "signer": "0x1111111111111111111111111111111111111111", + "tokenId": "71321045679252212594626385532706912750332728571942532289631379312455583992563", + "makerAmount": "1000000", + "takerAmount": "450000", + "side": 1, + "signatureType": 3, + "timestamp": "1773890761", + "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000", + "builder": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "order_hash": "0x787af0f37bfbe27085afb695ce44b2c47e2d958c16ed37da343ca16ff774b3a4", + "signature": "0x93d984e78deb9244f1b28706a0d10d5153d9e81ae88aa9d5a79e5c75415a0e24014ff1d049b730c1be6a44ce17c0757e396bfe6c0674200b46d4abb4ea794a6d1b466c63910185bbd55e8679264200c4e0abdcbb0c6264eb3d41d13326022e095b12dfa0aa58f46630df146d098455470afdf7f8f784975e4597fe49c724d9308f4f726465722875696e743235362073616c742c61646472657373206d616b65722c61646472657373207369676e65722c75696e7432353620746f6b656e49642c75696e74323536206d616b6572416d6f756e742c75696e743235362074616b6572416d6f756e742c75696e743820736964652c75696e7438207369676e6174757265547970652c75696e743235362074696d657374616d702c62797465733332206d657461646174612c62797465733332206275696c6465722900ba" + }, + { + "name": "proxy_buy", + "order": { + "salt": "12345", + "maker": "0x1111111111111111111111111111111111111111", + "signer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "tokenId": "789", + "makerAmount": "966191", + "takerAmount": "1932381", + "side": 0, + "signatureType": 1, + "timestamp": "1773890760", + "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000", + "builder": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "order_hash": "0x0d36de3d3775c157daa56f1a5780e8bac8abd3fbab8980302193674bc0738419", + "signature": "0x9543e13226a253e6deb53c3388d853ce77f3d4276e3ce6812905243f12448d977ba834988e67758e1902ada8d8c32a416efd783f8b0541a65c0e1ff8e05a56891b" + }, + { + "name": "safe_sell", + "order": { + "salt": "42", + "maker": "0x1111111111111111111111111111111111111111", + "signer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "tokenId": "789", + "makerAmount": "1000000", + "takerAmount": "450000", + "side": 1, + "signatureType": 2, + "timestamp": "1773890762", + "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000", + "builder": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "order_hash": "0xbbf9030e0fef8c752cada4ab5149eecfc7507186b1c17b339f4882f6296f7ec6", + "signature": "0x2647a2271471f167e73ed58883078a9fac7d0f8fb1a10bc528ad5c7720eb64563273456de75e7332d9de308bc3194281e45bab046dae7b408b37d7b0aff331da1b" + }, + { + "name": "eoa_maker_buy_no", + "order": { + "salt": "7", + "maker": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "signer": "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + "tokenId": "790", + "makerAmount": "550000", + "takerAmount": "1000000", + "side": 0, + "signatureType": 0, + "timestamp": "1773890763", + "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000", + "builder": "0x0000000000000000000000000000000000000000000000000000000000000000" + }, + "order_hash": "0x9fb78b45fc8d704835307fb27cc7e1e9f409e1c084d7898a663c71005a361cb6", + "signature": "0x2f05e6f98ca5b31fb182cd421e67586ab7c4a31d4640b591ae790dc3171c79fd4d9c717ff230d5cdfdb47c68c58f75b5627bf4202a93096cfb7dfd2525a22f9e1b" + } + ] +} diff --git a/tests/samples/sx_bet_parlay_request.json b/tests/samples/sx_bet_parlay_request.json new file mode 100644 index 0000000..570bc60 --- /dev/null +++ b/tests/samples/sx_bet_parlay_request.json @@ -0,0 +1,18 @@ +{ + "channelName": "markets:parlay", + "marketHash": "0x3f8893a68554eca5aaee57896505ea345e757b8809e8301b8ad8873a98b1c73b", + "requestorId": "08e489e45313812cf236b43ab68c6236e3e8d63375a07d6bafdec1ecbed85c1b", + "baseToken": "0x1BC6326EA6aF2aB8E4b6Bc83418044B1923b2956", + "requestSize": "50000000", + "chainVersion": "SXR", + "legs": [ + { + "marketHash": "0x22ed4cf508418f44e787f9c8e79f76eb31587efa20fe700b3582e09f01775944", + "bettingOutcomeOne": false + }, + { + "marketHash": "0x186685cf65e1a22952ad42982e1ec75b6a457fa3bdec59fa6f891258a718ddfe", + "bettingOutcomeOne": true + } + ] +} diff --git a/tests/serve_harness.py b/tests/serve_harness.py new file mode 100644 index 0000000..be845f1 --- /dev/null +++ b/tests/serve_harness.py @@ -0,0 +1,499 @@ +"""`synpath serve`, whole, in this process: mocked venues, fake streams, a real HTTP surface. + +`ServeHarness` builds `serve.build()` with + +* a **mocked Kalshi**: the real `KalshiTrading` adapter and the real `Kalshi` + read adapter, both on `httpx.MockTransport` (`MockKalshi`), so orders and + market-data routes run the library's own request and parsing code; other + venues' read adapters (or stand-ins) can be added with `exchanges=`; +* other venues' **trading adapters** (`trading=`, a `PaperVenue` say), so a + bucket across venues can be defined; +* a **mocked hosted API** (`MockHosted`) behind the matching and history + routes; +* **fake venue streams** (`FakeVenueStream`) for the engine's feeds and for + `/ws/market`, which the test drives by pushing the library's own events; +* a temporary journal (SQLite, or PostgreSQL when `SYNPATH_TEST_POSTGRES_DSN` + names a disposable database), control database and home directory. + +Everything runs on one event loop in a background thread (an anyio blocking +portal), and a Starlette `TestClient` is pointed at that same loop, so plain +synchronous tests drive REST and WebSockets while the engine, the feeds and +the hub run where they would in production. Nothing touches the network. + +```python +def test_something(serve_harness): + with serve_harness() as harness: + harness.client.get("/venues", headers=harness.auth) + with harness.client.websocket_connect(f"/ws/market?key={harness.token}&market_id=polymarket:1") as ws: + harness.push("polymarket", harness.stream("polymarket").snapshot("polymarket:1", bids=[("0.4", "10")])) +``` +""" +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from decimal import Decimal as D +from pathlib import Path +from typing import Any, Callable, Iterable + +import httpx +import pytest +from anyio.from_thread import start_blocking_portal + +import synpath +from synpath.engine.feeds import VenueStreams +from synpath.server import serve +from synpath.server.api import VenueRegistry +from synpath.trading.credentials import KalshiCredentials +from synpath.trading.kalshi import KalshiTrading +from synpath.ws.base import BookEvent, BookLevel, LocalBook, QuoteEvent, StreamStatusEvent, TradeEvent + +POSTGRES_DSN_VARIABLE = "SYNPATH_TEST_POSTGRES_DSN" +KALSHI_TRADE_API = "https://external-api.kalshi.com/trade-api/v2" +# Venues whose market stream needs no credentials; the fake factory opens these by default. +PUBLIC_STREAM_VENUES = ("polymarket", "hyperliquid", "limitless") +VENUES_WITHOUT_TRADE_STREAMS = ("limitless", "predict_fun") + + +# --------------------------------------------------------------------------- +# A mocked Kalshi +# --------------------------------------------------------------------------- + +def kalshi_credentials() -> KalshiCredentials: + """A throwaway RSA key: requests are signed for real, and go nowhere.""" + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import rsa + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + pem = key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, + serialization.NoEncryption()) + return KalshiCredentials(key_id="harness-key", private_key_pem=pem, env="demo") + + +class MockKalshi: + """Kalshi's REST API, enough for the read routes and a small order store. + Every request is recorded; anything unexpected answers 404. + + The order store models what the engine depends on: a create rests unless + `fill_on_create` is set (or the order is immediate-or-cancel, which + fills); `fill(order_id)` executes a resting order at the venue without + telling the engine; a cancel of an order that is no longer resting + answers 404 `not_found`, as Kalshi does, while a read of it still works; + `lose_create_response` accepts the order and then drops the response.""" + + def __init__(self, *, book: dict[str, list[list[str]]] | None = None, fill_on_create: bool = False, + lose_create_response: bool = False): + self.requests: list[httpx.Request] = [] + self.book = book or {"yes_dollars": [["0.40", "10"]], "no_dollars": [["0.55", "7"]]} + self.fill_on_create = fill_on_create + self.lose_create_response = lose_create_response + # Venue order records by order id, in Kalshi's own shape. + self.orders: dict[str, dict[str, Any]] = {} + self.creates: list[dict[str, Any]] = [] + self.cancels: list[str] = [] + # Order ids the venue answers 404 for on every route: no record at all. + self.forgotten: set[str] = set() + + def fill(self, order_id: str) -> None: + """Execute a resting order at the venue, wholly, without a stream update.""" + record = self.orders[order_id] + quantity = record["initial_count_fp"] + record.update(status="executed", fill_count_fp=quantity, remaining_count_fp="0", + taker_fill_cost_dollars=str(D(quantity) * D(record["yes_price_dollars"])), + taker_fees_dollars="0.01") + + def _create(self, request: httpx.Request) -> httpx.Response: + import json + + body = json.loads(request.content) + self.creates.append(body) + order_id = f"kx-{len(self.creates)}" + immediate = body.get("time_in_force") in ("immediate_or_cancel", "fill_or_kill") + filled = self.fill_on_create or immediate + count = body["count"] + self.orders[order_id] = { + "order_id": order_id, "client_order_id": body["client_order_id"], "ticker": body["ticker"], + "book_side": body["side"], "type": "limit", "status": "resting", "initial_count_fp": count, + "fill_count_fp": "0", "remaining_count_fp": count, "yes_price_dollars": body["price"], + } + if filled: + self.fill(order_id) + if self.lose_create_response: + raise httpx.ReadError("accepted; the response was lost", request=request) + return httpx.Response(201, json={"order_id": order_id, "client_order_id": body["client_order_id"], + "fill_count": count if filled else "0", + "remaining_count": "0" if filled else count, + "average_fill_price": body["price"] if filled else None}) + + def _cancel(self, order_id: str) -> httpx.Response: + self.cancels.append(order_id) + record = self.orders.get(order_id) + if record is None or order_id in self.forgotten or record["status"] != "resting": + return httpx.Response(404, json={"error": {"code": "not_found", "message": "order not found"}}) + reduced = record["remaining_count_fp"] + record.update(status="canceled", remaining_count_fp="0") + return httpx.Response(200, json={"order_id": order_id, "reduced_by": reduced}) + + def _orders(self, request: httpx.Request) -> httpx.Response: + wanted = request.url.params.get("status") + rows = [r for oid, r in self.orders.items() + if oid not in self.forgotten and (wanted is None or r["status"] == wanted)] + return httpx.Response(200, json={"orders": rows, "cursor": ""}) + + @staticmethod + def market(ticker: str) -> dict[str, Any]: + return {"ticker": ticker, "event_ticker": "KXHARNESS", "status": "active", "title": "Harness market", + "price_level_structure": "linear_cent", "yes_bid_dollars": "0.40", "yes_ask_dollars": "0.45"} + + def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + path = request.url.path.removeprefix("/trade-api/v2") + parts = path.strip("/").split("/") + if request.method == "GET" and parts[:1] == ["markets"] and len(parts) == 3 and parts[2] == "orderbook": + return httpx.Response(200, json={"orderbook_fp": self.book}) + if request.method == "GET" and parts[:1] == ["markets"] and len(parts) == 2: + return httpx.Response(200, json={"market": self.market(parts[1])}) + if request.method == "GET" and path in ("/markets", "/historical/markets") and request.url.params.get("tickers"): + # The batch read: only `KXHARNESS*` tickers exist, and only on the live endpoint. + tickers = request.url.params["tickers"].split(",") + found = [self.market(t) for t in tickers if t.startswith("KXHARNESS")] if path == "/markets" else [] + return httpx.Response(200, json={"markets": found, "cursor": ""}) + if request.method == "GET" and parts[:1] == ["series"]: + return httpx.Response(200, json={"series": {"ticker": parts[-1], "fee_type": "quadratic", + "fee_multiplier": 1}}) + if request.method == "POST" and path == "/portfolio/events/orders": + return self._create(request) + if request.method == "DELETE" and parts[:3] == ["portfolio", "events", "orders"] and len(parts) == 4: + return self._cancel(parts[3]) + if request.method == "GET" and parts[:2] == ["portfolio", "orders"] and len(parts) == 3 \ + and parts[2] in self.orders and parts[2] not in self.forgotten: + return httpx.Response(200, json={"order": self.orders[parts[2]]}) + if request.method == "GET" and path == "/portfolio/orders": + return self._orders(request) + if request.method == "GET" and path == "/portfolio/balance": + return httpx.Response(200, json={"balance": 1_000_000, "portfolio_value": 0}) + if request.method == "GET" and path in ("/portfolio/positions", "/portfolio/fills", "/portfolio/settlements"): + key = {"/portfolio/positions": "market_positions", "/portfolio/fills": "fills", + "/portfolio/settlements": "settlements"}[path] + return httpx.Response(200, json={key: [], "event_positions": [], "cursor": ""}) + return httpx.Response(404, json={"error": {"code": "not_found", "message": f"harness: {request.method} {path}"}}) + + +class MockHosted: + """Synpath's hosted services (matching at api.synpath.dev, history at + api2.synpath.dev), answering from `routes`: `(method, path)` -> a JSON body, + or `(status, body)`. Every request is recorded; an unknown route answers + 404 with no Synpath error body, as the host in front of an undeployed + service does.""" + + def __init__(self, routes: dict[tuple[str, str], Any] | None = None): + self.requests: list[httpx.Request] = [] + self.routes: dict[tuple[str, str], Any] = dict(routes or {}) + + def handle(self, request: httpx.Request) -> httpx.Response: + self.requests.append(request) + answer = self.routes.get((request.method, request.url.path)) + if answer is None: + return httpx.Response(404, text="not found") + status, body = answer if isinstance(answer, tuple) else (200, answer) + return httpx.Response(status, json=body) + + +# --------------------------------------------------------------------------- +# A fake venue stream +# --------------------------------------------------------------------------- + +class FakeVenueStream: + """Stands in for a library market stream (`KalshiStream`, + `PolymarketMarketStream`, …): the same `watch_*`, `unwatch`, `book()`, + `has` and async iteration, with `LocalBook`s the test fills through + `snapshot()`/`delta()`. Events reach subscribers only when pushed.""" + + def __init__(self, venue: str, *, private: bool = False, unwatch: bool = True, trades: bool = True): + self.venue, self.name = venue, f"fake-{venue}" + self.has = {"watch_order_book": True, "watch_ticker": True, "watch_trades": trades, + "watch_orders": private, "watch_my_trades": private} + if not unwatch: + self.unwatch = None # Kalshi and Polymarket US cannot unwatch + self.books: dict[str, LocalBook] = {} + self.watched: list[tuple[str, tuple[str, ...]]] = [] + self.unwatched: list[str] = [] + self.subscribed: list[str] = [] + self.queue: asyncio.Queue = asyncio.Queue() + self.started = self.closed = False + + # -- the stream interface ------------------------------------------------- + + def start(self) -> "FakeVenueStream": + self.started = True + return self + + async def close(self) -> None: + self.closed = True + self.queue.put_nowait(None) + + async def watch_order_book(self, market_ids: list[str]) -> None: + self.watched.append(("book", tuple(market_ids))) + for market_id in market_ids: + self.books.setdefault(market_id, LocalBook()) + + async def watch_trades(self, market_ids: list[str]) -> None: + self.watched.append(("trades", tuple(market_ids))) + + async def watch_ticker(self, market_ids: list[str]) -> None: + self.watched.append(("quotes", tuple(market_ids))) + + async def unwatch(self, market_ids: list[str]) -> None: # type: ignore[no-redef] + self.unwatched += market_ids + for market_id in market_ids: + self.books.pop(market_id, None) + + async def watch_orders(self, market_ids: list[str] | None = None) -> None: + self.subscribed.append("orders") + + async def watch_my_trades(self, market_ids: list[str] | None = None) -> None: + self.subscribed.append("fills") + + def book(self, market_id: str, side: str = "yes") -> LocalBook | None: + book = self.books.get(market_id) + return book.mirrored() if book is not None and side == "no" else book + + def __aiter__(self) -> "FakeVenueStream": + return self + + async def __anext__(self) -> Any: + event = await self.queue.get() + if event is None: + raise StopAsyncIteration + return event + + # -- what a test pushes ------------------------------------------------- + + def push(self, event: Any) -> None: + self.queue.put_nowait(event) + + def snapshot(self, market_id: str, *, bids: Iterable[tuple[str, str]] = (), + asks: Iterable[tuple[str, str]] = ()) -> BookEvent: + """Replace the book, as a venue snapshot does, and return its event.""" + bids, asks = [(D(p), D(s)) for p, s in bids], [(D(p), D(s)) for p, s in asks] + book = self.books.setdefault(market_id, LocalBook()) + book.replace(bids, asks) + return BookEvent(venue=self.venue, market_id=market_id, kind="snapshot", + bids=tuple(BookLevel(p, s) for p, s in bids), asks=tuple(BookLevel(p, s) for p, s in asks), + best_bid=book.best_bid, best_ask=book.best_ask) + + def delta(self, market_id: str, side: str, price: str, size: str) -> BookEvent: + book = self.books[market_id] + book.set(side, D(price), D(size)) + level = (BookLevel(D(price), D(size)),) + return BookEvent(venue=self.venue, market_id=market_id, kind="delta", + bids=level if side == "bid" else (), asks=level if side == "ask" else (), + best_bid=book.best_bid, best_ask=book.best_ask) + + def trade(self, market_id: str, price: str, quantity: str) -> TradeEvent: + return TradeEvent(venue=self.venue, market_id=market_id, id=f"t-{uuid.uuid4().hex[:6]}", + price=D(price), quantity=D(quantity), timestamp=int(time.time() * 1000)) + + def quote(self, market_id: str, bid: str, ask: str, side: str = "yes") -> QuoteEvent: + return QuoteEvent(venue=self.venue, market_id=market_id, side=side, bid=D(bid), ask=D(ask)) + + def drop(self) -> StreamStatusEvent: + """The venue socket dropped: every book stops being trusted, as + `Stream.on_disconnect` does.""" + for book in self.books.values(): + book.invalidate() + return StreamStatusEvent(venue=self.venue, stream=self.name, state="disconnected", detail="harness drop") + + def reconnected(self) -> StreamStatusEvent: + return StreamStatusEvent(venue=self.venue, stream=self.name, state="connected", detail="reconnected") + + +# --------------------------------------------------------------------------- +# The harness +# --------------------------------------------------------------------------- + +class ServeHarness: + """A built `synpath serve` stack on a background event loop. Use as a + context manager; `client`, `token` and `auth` are ready inside it.""" + + def __init__(self, tmp_path: Path, *, storage: str = "sqlite", postgres_dsn: str | None = None, + stream_venues: Iterable[str] = PUBLIC_STREAM_VENUES, engine_stream: bool = True, + config: dict[str, Any] | None = None, kalshi: MockKalshi | None = None, + exchanges: dict[str, Any] | None = None, hosted: "MockHosted | None" = None, + trading: dict[str, Any] | None = None): + self.tmp_path = tmp_path + self.storage = storage + self.postgres_dsn = postgres_dsn + self.postgres_schema = "synpath_harness_" + uuid.uuid4().hex if storage == "postgresql" else None + self.stream_venues = set(stream_venues) + self.kalshi = kalshi or MockKalshi() + # Read adapters (or stand-ins) for venues other than the mocked Kalshi. + self.exchanges = dict(exchanges or {}) + self.hosted = hosted or MockHosted() + # Trading adapters for venues other than the mocked Kalshi (a `PaperVenue`, say). + self.trading = dict(trading or {}) + self.engine_stream = FakeVenueStream("kalshi", private=True, unwatch=False) if engine_stream else None + # Market streams the hub opened itself, by venue, oldest first. + self.opened: dict[str, list[FakeVenueStream]] = {} + self.config = {"risk": {"price_collar": None, "duplicate_window_ms": 0, "closing_soon_s": None}, + "book_interval_s": 0, **(config or {})} + self.portal: Any = None + self._portal_context: Any = None + self.stack: Any = None + self.client: Any = None + self._loops: list[Any] = [] + + # -- what tests use ------------------------------------------------------- + + @property + def token(self) -> str: + return self.stack.owner_key + + @property + def auth(self) -> dict[str, str]: + return {"Authorization": f"Bearer {self.token}"} + + def call(self, fn: Callable[..., Any], *args: Any) -> Any: + """Run `fn(*args)` (sync or async) on the server's event loop.""" + return self.portal.call(fn, *args) + + def stream(self, venue: str, index: int = -1) -> FakeVenueStream: + """The fake stream the server reads `venue` from: the engine's for + Kalshi, otherwise the latest one the hub opened.""" + if venue == "kalshi" and self.engine_stream is not None: + return self.engine_stream + # The hub opens a venue's stream on the server's loop after the + # subscribe arrives; on a slow machine a test can get here first. + # Poll the dict directly: this is also called from conditions that + # already run on the server's loop, where a portal call would fail. + deadline = time.monotonic() + 10.0 + while not self.opened.get(venue) and time.monotonic() < deadline: + time.sleep(0.01) + return self.opened[venue][index] + + def push(self, venue: str, event: Any) -> None: + self.call(self.stream(venue).push, event) + + def wait_until(self, condition: Callable[[], bool], timeout: float = 3.0) -> None: + """Poll `condition` on the server's loop until true.""" + deadline = time.monotonic() + timeout + while not self.call(condition): + if time.monotonic() > deadline: + raise AssertionError("the server did not get there in time") + time.sleep(0.01) + + def remote_client(self) -> synpath.Client: + """`synpath.Client(server=...)` against this stack, in process.""" + http = httpx.AsyncClient(transport=httpx.ASGITransport(app=self.stack.app)) + return synpath.Client(server="http://synpath", access_token=self.token, server_http=http) + + # -- building and tearing down -------------------------------------------- + + def __enter__(self) -> "ServeHarness": + from fastapi.testclient import TestClient + + self._portal_context = start_blocking_portal("asyncio") + self.portal = self._portal_context.__enter__() + try: + self.stack = self.call(self._build) + self._loops = [self.portal.start_task_soon(lambda c=coro: c) for coro in self._feed_loops()] + self.client = TestClient(self.stack.app, base_url="http://synpath") + self.client.portal = self.portal # requests and sockets run on the server's loop + except BaseException: + self.__exit__(None, None, None) + raise + return self + + def __exit__(self, *exc: Any) -> None: + try: + for future in self._loops: + future.cancel() + if self.stack is not None: + self.call(serve.shutdown, self.stack) + if self.postgres_schema is not None: + self.call(self._drop_postgres_schema) + finally: + self._portal_context.__exit__(None, None, None) + + async def _build(self) -> Any: + credentials = kalshi_credentials() + trading_http = httpx.AsyncClient(transport=httpx.MockTransport(self.kalshi.handle)) + adapters = {"kalshi": KalshiTrading(credentials, base_url=KALSHI_TRADE_API, client=trading_http), + **self.trading} + read_http = httpx.Client(transport=httpx.MockTransport(self.kalshi.handle)) + registry = VenueRegistry({"kalshi": synpath.exchange("kalshi", base_url=KALSHI_TRADE_API, + client=read_http, limiter=None), **self.exchanges}) + stream_map = {"kalshi": VenueStreams(market=self.engine_stream, private=self.engine_stream)} \ + if self.engine_stream is not None else {} + return await serve.build( + config={**self.config, **self._storage_config()}, + journal=str(self.tmp_path / "serve.db"), control=str(self.tmp_path / "control.db"), + adapters=adapters, stream_map=stream_map, registry=registry, + market_stream_factory=self._open_fake_stream, home_dir=str(self.tmp_path / "home"), + hosted_http=httpx.Client(transport=httpx.MockTransport(self.hosted.handle)), + ) + + def _storage_config(self) -> dict[str, Any]: + if self.storage == "sqlite": + return {} + return {"storage_backend": "postgresql", "journal": self.postgres_dsn, "storage_schema": self.postgres_schema} + + def _open_fake_stream(self, venue: str) -> FakeVenueStream | None: + if venue not in self.stream_venues: + return None + # Like the real ones, the Limitless and predict.fun streams carry no trades. + stream = FakeVenueStream(venue, trades=venue not in VENUES_WITHOUT_TRADE_STREAMS) + self.opened.setdefault(venue, []).append(stream) + return stream + + def _feed_loops(self) -> list[Any]: + """The feeds' loops (pumps and subscription sync), not the whole of + `serve.background`: the poll, reconcile and status loops would only + add mocked venue traffic no test here reads.""" + if self.stack.feeds is None: + return [] + self.stack.feeds.sync_interval_s = 0.05 + return list(self.stack.feeds.background().values()) + + async def _drop_postgres_schema(self) -> None: + import psycopg + from psycopg import sql + + async with await psycopg.AsyncConnection.connect(self.postgres_dsn, autocommit=True) as connection: + await connection.execute(sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format( + sql.Identifier(self.postgres_schema))) + + +STORAGE_BACKENDS = ["sqlite", "postgresql"] + + +@pytest.fixture(params=STORAGE_BACKENDS) +def serve_harness(request: Any, tmp_path: Path) -> Callable[..., ServeHarness]: + """A factory for `ServeHarness` on each storage backend. PostgreSQL runs + only when `SYNPATH_TEST_POSTGRES_DSN` names a disposable database, as the + other PostgreSQL tests do; otherwise that case is skipped.""" + storage = request.param + dsn = os.environ.get(POSTGRES_DSN_VARIABLE) + if storage == "postgresql": + if not dsn: + pytest.skip(f"set {POSTGRES_DSN_VARIABLE} to a disposable PostgreSQL database") + pytest.importorskip("psycopg") + + def make(**kwargs: Any) -> ServeHarness: + return ServeHarness(tmp_path, storage=storage, postgres_dsn=dsn, **kwargs) + + return make + + +def receive_until(socket: Any, kind: str, *, limit: int = 20, **fields: Any) -> dict[str, Any]: + """The next message of `type == kind` (and matching `fields`), skipping others.""" + for _ in range(limit): + message = socket.receive_json() + if message.get("type") == kind and all(message.get(k) == v for k, v in fields.items()): + return message + raise AssertionError(f"no {kind} message {fields or ''} in {limit} frames") + + +__all__ = ["FakeVenueStream", "MockHosted", "MockKalshi", "ServeHarness", "serve_harness", "receive_until"] diff --git a/tests/test_betfair_ws.py b/tests/test_betfair_ws.py index 4cf7b67..e5fed23 100644 --- a/tests/test_betfair_ws.py +++ b/tests/test_betfair_ws.py @@ -72,11 +72,11 @@ def message(name: str, **changes) -> str: async def authenticated(stream): """A stream past authentication: the frame sent, Betfair's SUCCESS read.""" stream._ws = FakeSocket() - stream._dispatch(message("connection")) + stream._process_raw_frame_and_emit_events(message("connection")) await stream.on_connect() auth = stream._ws.sent[-1] assert auth == {"op": "authentication", "id": auth["id"], "appKey": "APP-KEY-DELAYED", "session": "SESSION-TOKEN-1"} - stream._dispatch(message("auth_success", id=auth["id"])) + stream._process_raw_frame_and_emit_events(message("auth_success", id=auth["id"])) await settle() return stream @@ -220,7 +220,7 @@ async def test_a_refused_session_is_dropped_so_the_next_connection_logs_in(self) stream = stream_with() stream._ws = FakeSocket() await stream.on_connect() - stream._dispatch(message("auth_failure", id=stream._auth_id)) + stream._process_raw_frame_and_emit_events(message("auth_failure", id=stream._auth_id)) assert stream.session._token is None assert drain(stream)[-1].state == "error" @@ -228,7 +228,7 @@ async def test_a_refused_app_key_fails_for_good(self): stream = stream_with() stream._ws = FakeSocket() await stream.on_connect() - stream._dispatch(message("app_key_failure", id=stream._auth_id)) + stream._process_raw_frame_and_emit_events(message("app_key_failure", id=stream._auth_id)) await settle() states = [e.state for e in drain(stream) if isinstance(e, StreamStatusEvent)] assert "failed" in states and stream._closing @@ -240,13 +240,13 @@ async def started(self, **kwargs): await stream.watch_order_book([ARSENAL]) await stream.watch_ticker([CHELSEA]) await authenticated(stream) - stream._dispatch(message("subscribe_success", id=stream._sub_id)) + stream._process_raw_frame_and_emit_events(message("subscribe_success", id=stream._sub_id)) drain(stream) return stream async def test_an_image_then_deltas(self): stream = await self.started() - stream._dispatch(message("image")) + stream._process_raw_frame_and_emit_events(message("image")) events = drain(stream) snapshot = next(e for e in events if isinstance(e, BookEvent)) assert snapshot.market_id == ARSENAL and snapshot.kind == "snapshot" @@ -257,7 +257,7 @@ async def test_an_image_then_deltas(self): assert quote.market_id == CHELSEA and quote.bid == yes_price(3.7) and quote.ask == yes_price(3.65) assert stream.initial_clk == "G1jHgcgNHPgjjcwRj5LFHw==" and stream.clk == "AAAAAAAAAAAAAA==" - stream._dispatch(message("delta")) + stream._process_raw_frame_and_emit_events(message("delta")) delta = next(e for e in drain(stream) if isinstance(e, BookEvent)) assert delta.kind == "delta" and stream.clk == "AAAAAAAAAAAAAB==" # Level 0 of batb moved from 2.08 to 2.10: the old price is gone, the new one in. @@ -273,22 +273,22 @@ async def test_an_image_then_deltas(self): async def test_a_heartbeat_moves_the_clock_only(self): stream = await self.started() - stream._dispatch(message("image")) + stream._process_raw_frame_and_emit_events(message("image")) drain(stream) - stream._dispatch(message("heartbeat")) + stream._process_raw_frame_and_emit_events(message("heartbeat")) assert drain(stream) == [] and stream.clk == "AAAAAAAAAAAAAD==" async def test_reconnect_resubscribes_with_the_clock_and_applies_the_delta(self): stream = await self.started() - stream._dispatch(message("image")) - stream._dispatch(message("delta")) + stream._process_raw_frame_and_emit_events(message("image")) + stream._process_raw_frame_and_emit_events(message("delta")) drain(stream) stream.on_disconnect() assert not stream.book(ARSENAL).ready await authenticated(stream) resubscription = stream._ws.sent[-1] assert resubscription["initialClk"] == "G1jHgcgNHPgjjcwRj5LFHw==" and resubscription["clk"] == "AAAAAAAAAAAAAB==" - stream._dispatch(message("resub_delta")) + stream._process_raw_frame_and_emit_events(message("resub_delta")) events = drain(stream) book = next(e for e in events if isinstance(e, BookEvent)) assert book.kind == "snapshot" and stream.book(ARSENAL).ready @@ -298,7 +298,7 @@ async def test_reconnect_resubscribes_with_the_clock_and_applies_the_delta(self) async def test_a_changed_market_set_starts_a_fresh_image(self): stream = await self.started() - stream._dispatch(message("image")) + stream._process_raw_frame_and_emit_events(message("image")) await stream.watch_order_book(["betfair:1.234567891-47972"]) subscription = stream._ws.sent[-1] assert subscription["marketFilter"]["marketIds"] == ["1.234567890", "1.234567891"] @@ -307,8 +307,8 @@ async def test_a_changed_market_set_starts_a_fresh_image(self): async def test_a_delayed_key_is_warned_about_once(self, caplog): stream = await self.started() with caplog.at_level(logging.WARNING, logger="synpath.ws.betfair"): - stream._dispatch(message("image")) - stream._dispatch(message("resub_delta")) + stream._process_raw_frame_and_emit_events(message("image")) + stream._process_raw_frame_and_emit_events(message("resub_delta")) warnings = [r for r in caplog.records if "conflated" in r.getMessage()] assert len(warnings) == 1 and str(DELAYED_CONFLATE_MS) in warnings[0].getMessage() assert stream.idle_timeout >= DELAYED_CONFLATE_MS / 1000 @@ -326,11 +326,11 @@ async def test_runner_status_changes(self): stream = stream_with() await stream.watch_market_status(["betfair:1.234567890"]) await authenticated(stream) - stream._dispatch(message("image")) + stream._process_raw_frame_and_emit_events(message("image")) opened = [e for e in drain(stream) if isinstance(e, MarketStatusEvent)] assert {e.market_id for e in opened} == {ARSENAL, CHELSEA, "betfair:1.234567890-58805"} assert {e.state for e in opened} == {"open"} - stream._dispatch(message("status_change")) + stream._process_raw_frame_and_emit_events(message("status_change")) changes = {e.market_id: e for e in drain(stream) if isinstance(e, MarketStatusEvent)} assert changes[ARSENAL].state == "paused" and changes[ARSENAL].info["in_play"] is True assert changes["betfair:1.234567890-58805"].state == "settled" @@ -350,7 +350,7 @@ async def test_betfairs_own_refusal_is_reported(self): stream = stream_with() await stream.watch_order_book([ARSENAL]) await authenticated(stream) - stream._dispatch(message("subscription_limit", id=stream._sub_id)) + stream._process_raw_frame_and_emit_events(message("subscription_limit", id=stream._sub_id)) assert "SUBSCRIPTION_LIMIT_EXCEEDED" in drain(stream)[-1].detail diff --git a/tests/test_cancel_after_fill.py b/tests/test_cancel_after_fill.py new file mode 100644 index 0000000..595be77 --- /dev/null +++ b/tests/test_cancel_after_fill.py @@ -0,0 +1,202 @@ +"""Cancelling an order the venue already finished keeps the venue's verdict. + +Kalshi answers a cancel of an order that is no longer resting with 404 +`not_found`. That is not a confirmation of cancellation: the order may have +filled. These tests drive the real `KalshiTrading` adapter over a mocked +Kalshi (`serve_harness.MockKalshi`) on both storage backends, for a plain +limit order, a stop-limit whose resting child filled, and a stop-market whose +immediate child filled, before and after a restart. +""" +from __future__ import annotations + +import os +import uuid +from decimal import Decimal as D +from types import SimpleNamespace + +import httpx +import pytest + +from serve_harness import KALSHI_TRADE_API, MockKalshi, kalshi_credentials +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.engine.storage_types import IntentState +from synpath.trading.errors import OrderNotFound +from synpath.trading.kalshi import KalshiTrading +from synpath.trading.types import OrderRequest, OrderStatus + +pytestmark = pytest.mark.anyio + +MARKET = "kalshi:KXHARNESS-1" + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(params=["sqlite", "postgresql"]) +async def engine_config(request, tmp_path): + """An `EngineConfig` factory on each backend; PostgreSQL only with a disposable DSN.""" + if request.param == "sqlite": + yield lambda: EngineConfig(journal_path=str(tmp_path / "engine.db")) + return + dsn = os.environ.get("SYNPATH_TEST_POSTGRES_DSN") + if not dsn: + pytest.skip("set SYNPATH_TEST_POSTGRES_DSN to a disposable PostgreSQL database") + psycopg = pytest.importorskip("psycopg") + schema = "synpath_cancel_" + uuid.uuid4().hex + try: + yield lambda: EngineConfig(journal_path=dsn, storage_backend="postgresql", storage_schema=schema) + finally: + from psycopg import sql + async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as connection: + await connection.execute(sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(sql.Identifier(schema))) + + +async def started(venue: MockKalshi, config: EngineConfig, credentials) -> Engine: + http = httpx.AsyncClient(transport=httpx.MockTransport(venue.handle)) + adapter = KalshiTrading(credentials, base_url=KALSHI_TRADE_API, client=http) + engine = Engine({"kalshi": adapter}, config, + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None)) + await engine.start() + return engine + + +@pytest.fixture +def credentials(): + return kalshi_credentials() + + +def limit(**changes) -> OrderRequest: + base = dict(market_id=MARKET, side="buy", quantity="2", price="0.40", client_order_id="limit-1") + return OrderRequest(**{**base, **changes}) + + +async def cancel_intent(engine: Engine, order_id: str): + return await engine.journal.intent(f"cancel:kalshi:{order_id}") + + +async def feed(engine: Engine, bid: str, ask: str) -> None: + engine.set_book(MARKET, SimpleNamespace(ready=True, best_bid=D(bid), best_ask=D(ask))) + await engine.on_book({"market_id": MARKET}) + + +# -- a plain limit order ------------------------------------------------------ + +async def test_cancel_after_the_venue_filled_a_resting_limit_reports_it_closed(engine_config, credentials): + venue = MockKalshi() + engine = await started(venue, engine_config(), credentials) + try: + placed = await engine.submit(limit()) + assert placed.status == OrderStatus.OPEN + venue.fill(placed.id) # filled at the venue; the engine has not heard yet + + result = await engine.cancel(placed.id) + + assert result.status == OrderStatus.CLOSED and result.filled == D("2") and result.remaining == 0 + stored = await engine.journal.order("kalshi", placed.id) + assert stored.status == OrderStatus.CLOSED and stored.filled == D("2") + assert stored.client_order_id == "limit-1", "the engine's own fields survive the read-back" + assert (await cancel_intent(engine, placed.id)).state == IntentState.SETTLED + assert engine.open_orders() == [] + assert venue.cancels == [placed.id] + finally: + await engine.stop() + + +async def test_cancel_of_an_order_the_engine_already_knows_filled_sends_nothing(engine_config, credentials): + venue = MockKalshi(fill_on_create=True) + engine = await started(venue, engine_config(), credentials) + try: + placed = await engine.submit(limit()) + assert placed.status == OrderStatus.CLOSED + + result = await engine.cancel(placed.id) + + assert result.status == OrderStatus.CLOSED and result.filled == D("2") + assert (await engine.journal.order("kalshi", placed.id)).status == OrderStatus.CLOSED + assert venue.cancels == [], "a finished order is not cancelled at the venue" + finally: + await engine.stop() + + +async def test_cancel_after_the_venue_cancelled_a_partly_filled_order_keeps_the_fill(engine_config, credentials): + venue = MockKalshi() + engine = await started(venue, engine_config(), credentials) + try: + placed = await engine.submit(limit()) + venue.orders[placed.id].update(status="canceled", fill_count_fp="1", remaining_count_fp="0", + taker_fill_cost_dollars="0.40") + + result = await engine.cancel(placed.id) + + assert result.status == OrderStatus.CANCELED and result.filled == D("1") + assert (await engine.journal.order("kalshi", placed.id)).filled == D("1") + finally: + await engine.stop() + + +async def test_a_venue_with_no_record_of_the_order_does_not_confirm_a_cancel(engine_config, credentials): + venue = MockKalshi() + engine = await started(venue, engine_config(), credentials) + try: + placed = await engine.submit(limit()) + venue.forgotten.add(placed.id) + + with pytest.raises(OrderNotFound, match="unconfirmed"): + await engine.cancel(placed.id) + + stored = await engine.journal.order("kalshi", placed.id) + assert stored.status == OrderStatus.OPEN, "the last venue observation stands" + assert (await cancel_intent(engine, placed.id)).state == IntentState.FAILED + finally: + await engine.stop() + + +# -- engine-held parents -------------------------------------------------------- + +async def test_cancel_after_a_stop_limit_child_filled_closes_the_parent(engine_config, credentials): + venue = MockKalshi() + engine = await started(venue, engine_config(), credentials) + try: + parent = await engine.submit(OrderRequest(market_id=MARKET, side="buy", quantity="2", type="stop_limit", + stop_price="0.45", price="0.46", client_order_id="stop-limit-1")) + await feed(engine, "0.44", "0.46") + child_id = engine.managed_order_controller.get(parent.id).children[0].order_id + assert venue.orders[child_id]["status"] == "resting" + venue.fill(child_id) # the resting child fills; no stream tells the engine + + result = await engine.cancel(parent.id) + + assert result.status == OrderStatus.CLOSED and result.filled == D("2") + child = await engine.journal.order("kalshi", child_id) + assert child.status == OrderStatus.CLOSED and child.filled == D("2") + stored = await engine.journal.managed_one(parent.id) + assert stored[1]["state"] == "done" + finally: + await engine.stop() + + +async def test_a_filled_stop_market_stays_closed_when_cancelled_before_and_after_restart(engine_config, credentials): + venue = MockKalshi() + config = engine_config() + engine = await started(venue, config, credentials) + try: + parent = await engine.submit(OrderRequest(market_id=MARKET, side="sell", quantity="2", type="stop_market", + stop_price="0.40", client_order_id="stop-market-1")) + await feed(engine, "0.39", "0.41") + assert len(venue.creates) == 1 and venue.creates[0]["time_in_force"] == "immediate_or_cancel" + assert (await engine.cancel(parent.id)).status == OrderStatus.CLOSED + finally: + await engine.stop() + + restarted = await started(venue, config, credentials) + try: + assert restarted.managed_order_controller.get(parent.id) is None, "a finished parent is not restored" + result = await restarted.cancel(parent.id) + assert result.status == OrderStatus.CLOSED and result.filled == D("2") + stored = await restarted.journal.order("kalshi", parent.id) + assert stored.status == OrderStatus.CLOSED + assert venue.cancels == [], "neither the parent id nor the finished child goes to the venue" + finally: + await restarted.stop() diff --git a/tests/test_combo_board.py b/tests/test_combo_board.py new file mode 100644 index 0000000..600353a --- /dev/null +++ b/tests/test_combo_board.py @@ -0,0 +1,307 @@ +"""The combo board (synpath/engine/combo_board.py), offline, on real venue data. + +The samples are trimmed reads of the venues' public catalogues on 2026-10-22 +(no auth): + +* `samples/polymarket_combo_board_live.json`: Polymarket's combo catalogue + rows (`/v1/rfq/combo-markets`), Gamma's markets for them (`sportsMarketType`, + `line`, `groupItemTitle`) and Gamma's events (`teams` with `abbreviation`): + an NFL game, an NBA game, a La Liga match (one market per result, and the + draw), an ATP match and a politics question; +* `samples/polymarket_us_combo_board_live.json`: the US gateway's events with + their combo-enabled markets (`marketType`, `marketSides[].team` with + `displayAbbreviation`): a CBA, KHL, NPB, cricket, esports and table tennis + match, and a Liga Colombia match (drawable); +* `samples/kalshi_combo_board_live.json`: the events of Kalshi's open + collection (`product_metadata.competition_scope`, `sub_title`, + `yes_sub_title`, `custom_strike`, `floor_strike`): NFL, NBA, NHL and + Serie A games (winner, spread and total events), an ATP match, NFL team + totals and an NCAAF first half (questions). + +Covered: each venue's games with their participants, codes, the side each +pick backs and its label, by the venue's own type and team fields (never a +URL slug or title wording); questions for everything else, with named +outcomes on their buttons; categories; and the readers (`board_for`). +""" +from __future__ import annotations + +import json +from decimal import Decimal as D +from pathlib import Path + +import pytest + +from synpath.engine.combo_board import ( + board_for, board_of, kalshi_markets, polymarket_markets, polymarket_us_markets, +) +from synpath.engine.paper import PaperRfqVenue +from synpath.kalshi import normalize_event as kalshi_event +from synpath.polymarket import normalize_event as gamma_event +from synpath.polymarket import normalize_market as gamma_market +from synpath.trading.polymarket_rfq import combo_market_of +from synpath.trading.polymarket_us import combo_markets_of +from synpath.types import Page + +SAMPLES = Path(__file__).parent / "samples" +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def sample(name: str) -> dict: + return json.loads((SAMPLES / name).read_text()) + + +POLY = sample("polymarket_combo_board_live.json") +US = sample("polymarket_us_combo_board_live.json") +KALSHI = sample("kalshi_combo_board_live.json") + + +def poly_board(): + rows = [combo_market_of(r) for r in POLY["catalogue"]] + return board_of("polymarket", polymarket_markets(rows, {m["id"]: m for m in POLY["gamma_markets"]}, + {e["id"]: e for e in POLY["gamma_events"]})) + + +def us_rows(): + return [row for event in US["events"] for row in combo_markets_of(event)] + + +def kalshi_events(): + return [kalshi_event(e) for e in KALSHI["events"]] + + +def game(board, title): + return next(e for e in board.events if e.kind == "game" and e.title == title) + + +def rows(event): + return [(r.name, r.abbr) for r in event.rows] + + +def picks(event, kind): + return [(p.label, p.outcome, p.market_id) if p else None for p in (r.picks[kind] for r in event.rows)] + + +class TestPolymarketLive: + def test_an_nfl_game_by_gamma_types_with_the_events_teams_and_codes(self): + nfl = game(poly_board(), "Patriots vs. Bears") + assert nfl.id == "polymarket-event:1004101" and nfl.category == "nfl" + assert nfl.market_types == ["moneyline", "spread", "total"] + # Gamma's teams in the event's order, with their abbreviations; the outcomes say Patriots / Bears. + assert rows(nfl) == [("New England Patriots", "NE"), ("Chicago Bears", "CHI")] + assert picks(nfl, "moneyline") == [("NE", "yes", "polymarket:4465810"), ("CHI", "no", "polymarket:4465810")] + # A spread is YES for its first outcome at its line (Bears -2.5), NO for the other at the opposite. + assert picks(nfl, "spread") == [("NE +2.5", "no", "polymarket:5377299"), ("CHI -2.5", "yes", "polymarket:5377299")] + assert [p[:2] for p in picks(nfl, "total")] == [("O 50.5", "yes"), ("U 50.5", "no")] + + def test_an_nba_game(self): + nba = game(poly_board(), "Cavaliers vs. 76ers") + assert nba.category == "nba" and rows(nba) == [("Cavaliers", "CLE"), ("76ers", "PHI")] + assert [p[:2] for p in picks(nba, "moneyline")] == [("CLE", "yes"), ("PHI", "no")] + assert [p[:2] for p in picks(nba, "spread")] == [("CLE +3.5", "no"), ("PHI -3.5", "yes")] + assert [p[:2] for p in picks(nba, "total")] == [("O 235.5", "yes"), ("U 235.5", "no")] + + def test_a_soccer_match_is_one_yes_no_market_per_result_by_its_label(self): + match = game(poly_board(), "Rayo Vallecano de Madrid vs. Athletic Club") + assert match.category == "soccer" + assert rows(match) == [("Rayo Vallecano de Madrid", "RAY"), ("Athletic Club", "BIL"), ("Draw", None)] + assert picks(match, "moneyline") == [("RAY", "yes", "polymarket:5014977"), ("BIL", "yes", "polymarket:5014980"), + ("Draw", "yes", "polymarket:5014978")] + + def test_a_tennis_match_and_a_politics_question(self): + board = poly_board() + tennis = game(board, "Almaty Open: Daniil Medvedev vs Sho Shimabukuro") + assert tennis.category == "tennis" and [r.name for r in tennis.rows] == ["Daniil Medvedev", "Sho Shimabukuro"] + assert [p[1] for p in picks(tennis, "moneyline")] == ["yes", "no"] + iran = next(e for e in board.events if e.id == "polymarket:665374") + assert iran.kind == "question" and iran.category == "politics" and (iran.yes.label, iran.no.label) == ("YES", "NO") + + def test_without_gammas_market_type_a_row_is_a_question_not_a_title_guess(self): + rows_ = [combo_market_of(r) for r in POLY["catalogue"]] + board = board_of("polymarket", polymarket_markets(rows_)) + assert all(e.kind == "question" for e in board.events) + named = next(e for e in board.events if e.id == "polymarket:4465810") + assert (named.yes.label, named.no.label) == ("Patriots", "Bears"), "named outcomes say their names" + + +class TestPolymarketUsLive: + def test_moneylines_take_their_teams_and_codes_from_market_sides(self): + board = board_of("polymarket_us", polymarket_us_markets(us_rows())) + cba = game(board, "Shandong Hi-Speed Kirin vs. Tianjin Pioneers") + assert rows(cba) == [("Shandong Hi-Speed Kirin", "SDK"), ("Tianjin Pioneers", "TIA")] + assert picks(cba, "moneyline") == [("SDK", "yes", "polymarket_us:aec-cba-sdk-tia-2026-10-25"), + ("TIA", "no", "polymarket_us:aec-cba-sdk-tia-2026-10-25")] + khl = game(board, "Amur Khabarovsk vs. Admiral Vladivostok") + assert rows(khl) == [("Amur Khabarovsk", "AMR"), ("Admiral Vladivostok", "ADM")] + cricket = game(board, "New Zealand vs. India") + assert rows(cricket) == [("New Zealand", "NZE"), ("India", "INDIA")] + assert all(e.market_types == ["moneyline"] for e in board.events if e.kind == "game") + + def test_a_drawable_match_is_one_market_per_team_and_one_for_the_draw(self): + board = board_of("polymarket_us", polymarket_us_markets(us_rows())) + match = game(board, "CD Tolima vs. AD Pasto") + assert match.category == "soccer" + assert rows(match) == [("CD Tolima", "TOL"), ("AD Pasto", "ADP"), ("Draw", None)] + assert [(p[0], p[1], p[2].rsplit("-", 1)[-1]) for p in picks(match, "moneyline")] == [ + ("TOL", "yes", "tol"), ("ADP", "yes", "adp"), ("Draw", "yes", "draw")] + + def test_every_listed_game_is_a_game(self): + board = board_of("polymarket_us", polymarket_us_markets(us_rows())) + assert len([e for e in board.events if e.kind == "game"]) == len(US["events"]) == 7 + assert board.count == len(us_rows()) + + +class TestKalshiLive: + def test_a_game_joins_its_winner_spread_and_total_events_by_competition_scope(self): + board = board_of("kalshi", kalshi_markets(kalshi_events())) + nfl = game(board, "New England vs Chicago") + assert nfl.category == "nfl" and nfl.market_types == ["moneyline", "spread", "total"] + # Names from the winner markets' yes_sub_title, codes from the event's sub_title (NE vs CHI). + assert rows(nfl) == [("New England", "NE"), ("Chicago", "CHI")] + assert picks(nfl, "moneyline") == [("NE", "yes", "kalshi:KXNFLGAME-26OCT22NECHI-NE"), + ("CHI", "yes", "kalshi:KXNFLGAME-26OCT22NECHI-CHI")] + # Each team's own spread market (its custom_strike is the team's), at floor_strike: "wins by over 2.5". + assert picks(nfl, "spread") == [("NE -1.5", "yes", "kalshi:KXNFLSPREAD-26OCT22NECHI-NE2"), + ("CHI -2.5", "yes", "kalshi:KXNFLSPREAD-26OCT22NECHI-CHI3")] + assert picks(nfl, "total") == [("O 50.5", "yes", "kalshi:KXNFLTOTAL-26OCT22NECHI-51"), + ("U 50.5", "no", "kalshi:KXNFLTOTAL-26OCT22NECHI-51")] + + def test_nhl_nba_and_a_soccer_match_with_its_tie(self): + board = board_of("kalshi", kalshi_markets(kalshi_events())) + nhl = game(board, "Vegas vs Columbus") + assert nhl.category == "nhl" and rows(nhl) == [("Vegas", "VGK"), ("Columbus", "CBJ")] + assert [p[0] for p in picks(nhl, "spread")] == ["VGK -1.5", "CBJ -1.5"] + nba = game(board, "New Orleans vs Utah") + assert nba.category == "nba" and rows(nba) == [("New Orleans", "NOP"), ("Utah", "UTA")] + serie_a = game(board, "Monza vs Napoli") + assert serie_a.category == "soccer" + assert rows(serie_a) == [("Monza", "MON"), ("Napoli", "NAP"), ("Draw", None)] + assert picks(serie_a, "moneyline")[2] == ("Draw", "yes", "kalshi:KXSERIEAGAME-26OCT28MONNAP-TIE") + assert [p[0] for p in picks(serie_a, "total")[:2]] == ["O 2.5", "U 2.5"] + + def test_a_tennis_match_names_its_players_in_full_with_the_sub_titles_short_names(self): + board = board_of("kalshi", kalshi_markets(kalshi_events())) + tennis = game(board, "Chidekh vs Cerundolo") + assert tennis.category == "tennis" + assert rows(tennis) == [("Clement Chidekh", "Chidekh"), ("Francisco Cerundolo", "Cerundolo")] + + def test_other_scopes_are_questions(self): + board = board_of("kalshi", kalshi_markets(kalshi_events())) + questions = [e.title for e in board.events if e.kind == "question"] + assert any("1st half" in q for q in questions), "an NCAAF first half is not the game's moneyline" + assert any("over" in q and "points scored" in q for q in questions), "a team total is not the game's total" + assert len([e for e in board.events if e.kind == "game"]) == 5 + + def test_an_event_without_a_scope_is_questions_not_a_title_guess(self): + events = [dict(e, product_metadata=None) for e in KALSHI["events"]] + board = board_of("kalshi", kalshi_markets([kalshi_event(e) for e in events])) + assert all(e.kind == "question" for e in board.events) + + +# -- the readers ------------------------------------------------------------- + +class CollectionPaper(PaperRfqVenue): + async def fetch_combo_collections(self, *, status=None, event_id=None, limit=None, cursor=None, **_): + return Page([{"collection_ticker": "KXMVE", "associated_event_tickers": [e["event_ticker"] + for e in KALSHI["events"]]}]) + + +class ListedPaper(PaperRfqVenue): + def __init__(self, rows, **kw): + super().__init__(**kw) + self.rows = rows + + async def fetch_combo_eligible_markets(self, *, event_id=None, tag=None, limit=None, cursor=None): + half = len(self.rows) // 2 + return Page(self.rows[:half], next_cursor="p2") if cursor is None else Page(self.rows[half:]) + + +class KalshiReader: + def __init__(self): + self.asked: list[list[str]] = [] + + def fetch_events_by_ids(self, event_ids): + self.asked.append(list(event_ids)) + return [e for e in kalshi_events() if e.id in event_ids] + + +class GammaReader: + """The server's Polymarket read adapter: Gamma's markets and events.""" + + def __init__(self, ask: str | None = None): + self.ask = ask + self.events_asked: list[list[str]] = [] + + def fetch_markets_by_ids(self, market_ids): + wanted = {m.partition(":")[2] for m in market_ids} + out = [] + for raw in POLY["gamma_markets"]: + if raw["id"] in wanted: + out.append(gamma_market({**raw, **({"bestAsk": self.ask} if self.ask and raw["id"] == "4465810" else {})})) + return out + + def fetch_events_by_ids(self, event_ids): + self.events_asked.append(list(event_ids)) + return [gamma_event(e) for e in POLY["gamma_events"] if f"polymarket:{e['id']}" in event_ids] + + +class TestReaders: + async def test_kalshi_reads_its_collections_events_from_the_catalogue(self): + reader = KalshiReader() + board = await board_for("kalshi", CollectionPaper(venue="kalshi"), reader) + assert reader.asked == [[f"kalshi:{e['event_ticker']}" for e in KALSHI["events"]]] + assert board.complete and len([e for e in board.events if e.kind == "game"]) == 5 + + async def test_kalshi_without_a_catalogue_has_an_empty_board(self): + board = await board_for("kalshi", CollectionPaper(venue="kalshi"), None) + assert board.events == [] and not board.complete + + async def test_polymarket_takes_gammas_markets_and_the_games_teams(self): + reader = GammaReader(ask="0.47") + rows_ = [combo_market_of(r) for r in POLY["catalogue"]] + board = await board_for("polymarket", ListedPaper(rows_, venue="polymarket"), reader) + nfl = game(board, "Patriots vs. Bears") + assert rows(nfl) == [("New England Patriots", "NE"), ("Chicago Bears", "CHI")] + assert nfl.rows[0].picks["moneyline"].price == D("0.47"), "the catalogue's ask" + assert board.complete and board.count == len(rows_) + assert sorted(reader.events_asked[0]) == sorted(f"polymarket:{e['id']}" for e in POLY["gamma_events"]) + + async def test_polymarket_us_rows_carry_their_own_teams(self): + board = await board_for("polymarket_us", ListedPaper(us_rows(), venue="polymarket_us"), None) + assert rows(game(board, "CD Tolima vs. AD Pasto"))[:2] == [("CD Tolima", "TOL"), ("AD Pasto", "ADP")] + + async def test_a_venue_without_combo_lists_has_an_empty_board(self): + board = await board_for("limitless", PaperRfqVenue(venue="limitless"), None) + assert board.events == [] and board.count == 0 + + +class TestGammaEventsByIds: + def test_batched_in_the_order_asked_with_the_teams_kept(self): + from synpath.polymarket import Polymarket + + calls = [] + + class Gamma: + def get(self, path, params=None): + calls.append((path, params)) + return [dict(e, markets=[]) for e in reversed(POLY["gamma_events"])] + + poly = Polymarket.__new__(Polymarket) + poly.gamma = Gamma() + ids_ = [f"polymarket:{e['id']}" for e in POLY["gamma_events"]] + ["polymarket:999"] + found = poly.fetch_events_by_ids(ids_) + assert [e.venue_event_id for e in found] == [e["id"] for e in POLY["gamma_events"]] + assert found[0].info["teams"][0]["abbreviation"] + assert calls[0][0] == "/events" and ("id", "999") in calls[0][1] + + +def test_names_match_by_whole_words_only(): + from synpath.engine.combo_board import same_name + + assert same_name("Patriots", "New England Patriots") and same_name("Chidekh", "Clement Chidekh") + assert not same_name("Ohio State", "Penn State"), "two teams sharing a last word are two teams" + assert not same_name("Bears", "Bearsden") diff --git a/tests/test_combo_warmer.py b/tests/test_combo_warmer.py new file mode 100644 index 0000000..d8f40f6 --- /dev/null +++ b/tests/test_combo_warmer.py @@ -0,0 +1,453 @@ +"""serve's warm combo boards and the combo skip reasons, offline. + +* The warmer (`synpath/server/combo_warmer.py`): every combo venue's board + built at start-up, through the venue's whole list (no page cap), rebuilt on + its interval, the last good board kept when a rebuild fails, and its state. +* The board routes on it: `GET /combos/board/categories`, one category a page + at a time on `GET /combos/board?category=&cursor=&limit=&q=&sort=`, the + whole board as before, and `status: "building"` at once while the first + build runs. +* The skip reasons of a cross-venue combo, on Yuki's real Polymarket combo of + 2026-10-22: "Will Al Fayha Saudi Club win on 2026-10-22?" + (`polymarket:5441701`, matched on Polymarket US only) and "Will the U.S. + invade Iran before 2027?" (`polymarket:665374`, matched nowhere), with + hosted matching mocked from its real answers and Gamma's real rows for both + (`samples/polymarket_combo_skip_legs_live.json`, no auth). +""" +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + +import httpx +import pytest + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.engine.combo_board import board_of, market_labels, polymarket_markets, short_question +from synpath.engine.paper import PaperRfqVenue +from synpath.engine.rfq_group import hosted_matcher +from synpath.polymarket import normalize_market as gamma_market +from synpath.server.combo_warmer import ComboBoardWarmer +from synpath.server.store import ControlStore +from synpath.server.trading import create_trading_app +from synpath.trading.types import Account, ComboLeg, ComboMarket +from synpath.types import Page + +from test_engine_rfq_group import RISK, CollectionVenue, ListedVenue + +pytestmark = pytest.mark.anyio + +SAMPLE = json.loads((Path(__file__).parent / "samples" / "polymarket_combo_skip_legs_live.json").read_text()) +AL_FAYHA, IRAN = "polymarket:5441701", "polymarket:665374" +AL_FAYHA_US = "polymarket_us:atc-spl-haz-fay-2026-10-22-fay" + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(autouse=True) +def no_saved_key(monkeypatch, tmp_path): + monkeypatch.delenv("SYNPATH_API_KEY", raising=False) + monkeypatch.setenv("SYNPATH_CREDENTIALS_FILE", str(tmp_path / "no-credentials.json")) + + +# -- a venue with a long eligible list ---------------------------------------------- + +def question(n: int, tag: str) -> ComboMarket: + return ComboMarket(market_id=f"polymarket:{n}", venue="polymarket", title=f"Will thing {n} happen?", tags=[tag], + info={"volume": str(n), "outcome_prices": ["0.5", "0.5"]}) + + +class PagedVenue(PaperRfqVenue): + """A Polymarket-like list of `pages` pages; each build may be held or fail.""" + + def __init__(self, pages: int, per_page: int = 10, **kw: Any): + super().__init__(venue="polymarket", **kw) + self.has = {**self.has, "combos": True} + tags = ("nfl", "politics", "crypto") + self.rows = [question(n, tags[n % 3]) for n in range(pages * per_page)] + self.per_page = per_page + self.reads = 0 + self.fail = False + self.hold: asyncio.Event | None = None + # Hold the read of this page (1-based) until `hold` is set. + self.block_at: int | None = None + + async def fetch_combo_eligible_markets(self, *, event_id=None, tag=None, limit=None, cursor=None) -> Page: + if self.hold is not None and (self.block_at is None or self.reads + 1 == self.block_at): + await self.hold.wait() + if self.fail: + raise ConnectionError("the catalogue is down") + self.reads += 1 + start = int(cursor or 0) + page = self.rows[start:start + self.per_page] + more = start + self.per_page < len(self.rows) + return Page(page, next_cursor=str(start + self.per_page) if more else None) + + +class FakeEngine: + def __init__(self, adapters): + self.adapters = adapters + + +class TestWarmer: + async def test_builds_the_whole_list_at_start_up_with_no_page_cap(self): + venue = PagedVenue(pages=12) # more than the 5 pages a board read on request takes + warmer = ComboBoardWarmer(FakeEngine({"polymarket": venue}), pause_s=0) + assert warmer.venues() == ["polymarket"] + first = warmer.board("polymarket") # starts the build, does not wait for it + assert first.status == "building" and first.events == [] + await warmer.ensure("polymarket") + board = warmer.board("polymarket") + assert board.status == "ready" and board.complete and board.count == 120 and venue.reads == 12 + assert board.built_at and warmer.state("polymarket")["status"] == "ready" + + async def test_refreshes_and_keeps_the_last_good_board_when_a_refresh_fails(self): + venue = PagedVenue(pages=2) + warmer = ComboBoardWarmer(FakeEngine({"polymarket": venue}), pause_s=0) + await warmer.build("polymarket") + built = warmer.board("polymarket").built_at + venue.rows = venue.rows[:5] + await warmer.build("polymarket") + assert warmer.board("polymarket").count == 5, "a refresh replaces the board" + venue.fail = True + await warmer.build("polymarket") + kept = warmer.board("polymarket") + assert kept.count == 5 and kept.status == "ready" and "catalogue is down" in kept.error + assert kept.built_at >= built and not warmer.state("polymarket")["refreshing"] + + async def test_the_loop_builds_at_once_then_on_its_interval_and_never_raises(self): + venue = PagedVenue(pages=1) + warmer = ComboBoardWarmer(FakeEngine({"polymarket": venue}), refresh_interval_s=0.01, pause_s=0) + task = asyncio.create_task(warmer.loop()) + for _ in range(200): + await asyncio.sleep(0.005) + if venue.reads >= 3: + break + venue.fail = True + await asyncio.sleep(0.05) + assert not task.done(), "a failing build never stops serve's loop" + task.cancel() + await warmer.close() + assert venue.reads >= 3 and warmer.board("polymarket").count == 10 + + async def test_a_long_first_build_serves_its_busiest_pages_first(self): + venue = PagedVenue(pages=12) + venue.hold, venue.block_at = asyncio.Event(), 7 + warmer = ComboBoardWarmer(FakeEngine({"polymarket": venue}), pause_s=0) + task = warmer.ensure("polymarket") + for _ in range(200): + await asyncio.sleep(0.005) + if warmer.board("polymarket").status == "ready": + break + partial = warmer.board("polymarket") + assert partial.status == "ready" and not partial.complete and partial.count == 50, "after the first 5 pages" + venue.hold.set() + await task + whole = warmer.board("polymarket") + assert whole.complete and whole.count == 120 + # A refresh never swaps the whole board for a partial one. + venue.hold, venue.reads = asyncio.Event(), 0 + refresh = warmer.ensure("polymarket") + for _ in range(50): + await asyncio.sleep(0.005) + assert warmer.board("polymarket").complete and warmer.state("polymarket")["refreshing"] + venue.hold.set() + await refresh + + async def test_a_venue_without_combo_lists_is_not_warmed(self): + warmer = ComboBoardWarmer(FakeEngine({"limitless": PaperRfqVenue(venue="limitless")})) + assert warmer.venues() == [] and not warmer.warms("limitless") + + +# -- the routes on a warm board ---------------------------------------------------------- + +@pytest.fixture +async def server(tmp_path: Path): + from fastapi.testclient import TestClient + + venue = PagedVenue(pages=3) + venue.account = Account(venue="polymarket", name="desk-a") + engine = Engine({"polymarket": venue}, EngineConfig(journal_path=str(tmp_path / "engine.db"), in_doubt_timeout_s=0, + rfq_incoming_interval_s=None), + risk=RiskConfig(**RISK), accounts={"polymarket": venue.account}) + await engine.start() + store = await ControlStore(str(tmp_path / "control.db")).open() + owner, issued = await store.bootstrap("owner") + app = create_trading_app(engine, store) + warmer = ComboBoardWarmer(engine, pause_s=0) + app.state.combo_boards = warmer + with TestClient(app) as client: + yield {"client": client, "venue": venue, "warmer": warmer, "headers": {"Authorization": f"Bearer {issued.secret}"}} + await store.close() + await engine.stop() + + +def wait_ready(server) -> None: + client = server["client"] + for _ in range(200): + if client.get("/combos/board/categories?venue=polymarket", headers=server["headers"]).json()["status"] == "ready": + return + import time + time.sleep(0.01) + raise AssertionError("the board never became ready") + + +class TestRoutes: + async def test_building_answers_at_once_then_the_board_fills_in(self, server): + client, venue = server["client"], server["venue"] + venue.hold = asyncio.Event() + categories = client.get("/combos/board/categories?venue=polymarket", headers=server["headers"]) + assert categories.status_code == 200 and categories.json()["status"] == "building" + whole = client.get("/combos/board?venue=polymarket", headers=server["headers"]).json() + assert whole["status"] == "building" and whole["events"] == [] + page = client.get("/combos/board?venue=polymarket&category=nfl", headers=server["headers"]).json() + assert page["status"] == "building" and page["total"] == 0 and page["next_cursor"] is None + client.portal.call(venue.hold.set) + wait_ready(server) + + async def test_categories_then_one_category_a_page_at_a_time(self, server): + client = server["client"] + wait_ready(server) + rail = client.get("/combos/board/categories?venue=polymarket", headers=server["headers"]).json() + assert rail["events"] == 30 and rail["complete"] and rail["built_at"] + assert {c["key"]: c["count"] for c in rail["categories"]} == {"nfl": 10, "politics": 10, "crypto": 10} + assert "events" not in rail["categories"][0] + first = client.get("/combos/board?venue=polymarket&category=politics&limit=4&sort=volume", + headers=server["headers"]).json() + assert first["category"] == "politics" and first["total"] == 10 and len(first["events"]) == 4 + assert first["next_cursor"] == "4" and {e["category"] for e in first["events"]} == {"politics"} + volumes = [float(e["volume"]) for e in first["events"]] + assert volumes == sorted(volumes, reverse=True) + assert len(first["categories"]) == 3, "a page carries the rail's categories" + seen = [e["id"] for e in first["events"]] + cursor = first["next_cursor"] + while cursor: + page = client.get(f"/combos/board?venue=polymarket&category=politics&limit=4&sort=volume&cursor={cursor}", + headers=server["headers"]).json() + seen += [e["id"] for e in page["events"]] + cursor = page["next_cursor"] + assert len(seen) == len(set(seen)) == 10 + found = client.get("/combos/board?venue=polymarket&category=all&q=thing%2012", headers=server["headers"]).json() + assert [e["id"] for e in found["events"]] == ["polymarket:12"] and found["total"] == 1 + bad = client.get("/combos/board?venue=polymarket&category=nfl&cursor=x", headers=server["headers"]) + assert bad.status_code == 400 + + async def test_the_whole_board_call_still_works(self, server): + wait_ready(server) + whole = server["client"].get("/combos/board?venue=polymarket", headers=server["headers"]).json() + assert whole["status"] == "ready" and len(whole["events"]) == 30 and whole["next_cursor"] is None + assert whole["category"] is None and whole["count"] == 30 + + async def test_openapi_lists_the_new_routes(self, server): + paths = server["client"].get("/openapi.json").json()["paths"] + assert "get" in paths["/combos/board/categories"] + params = {p["name"] for p in paths["/combos/board"]["get"]["parameters"]} + assert {"venue", "category", "cursor", "limit", "q", "sort", "pages"} <= params + + +# -- skip reasons on the two real legs ------------------------------------------------------- + +class GammaReader: + """serve's Polymarket read adapter, on Gamma's real rows for both legs.""" + + def fetch_markets_by_ids(self, market_ids): + wanted = {m.partition(":")[2] for m in market_ids} + return [gamma_market(raw) for raw in SAMPLE["gamma_markets"] if raw["id"] in wanted] + + def fetch_events_by_ids(self, event_ids): + from synpath.polymarket import normalize_event + return [normalize_event(e) for e in SAMPLE["gamma_events"] if f"polymarket:{e['id']}" in event_ids] + + +class Registry: + def __init__(self, readers): + self.readers = readers + + def get(self, venue): + return self.readers[venue] + + +def catalogue_row(raw: dict) -> ComboMarket: + tags = ["sports", "games", "soccer", "saudi-professional-league"] if raw.get("sportsMarketType") else ["politics"] + return ComboMarket(market_id=f"polymarket:{raw['id']}", venue="polymarket", title=raw["question"], tags=tags, + position_ids={"yes": f"{raw['id']}-Y", "no": f"{raw['id']}-N"}, info={"volume": raw.get("volume")}) + + +# Hosted matching's answers for Yuki's legs on 2026-10-22: Al Fayha is listed on +# Polymarket US and not on Kalshi; the Iran question is listed nowhere else. +MATCHES = { + (AL_FAYHA, "polymarket_us"): {"id": AL_FAYHA_US, "venue": "polymarket_us", "side_map": {"yes": "yes", "no": "no"}}, + (AL_FAYHA, "kalshi"): None, + (IRAN, "polymarket_us"): None, + (IRAN, "kalshi"): None, +} + + +def matching(down: bool = False): + def handler(request: httpx.Request) -> httpx.Response: + if down: + return httpx.Response(503, json={"detail": "matching is down"}) + key = (request.url.params["id"], request.url.params.get("venue")) + if key not in MATCHES: + return httpx.Response(404, json={"detail": "market not found"}) + return httpx.Response(200, json={"anchor": key[0], "event_id": "evt", "matched": MATCHES[key]}) + + return hosted_matcher(client=httpx.Client(transport=httpx.MockTransport(handler)), api_key="spk_test") + + +def combo_venues() -> dict[str, PaperRfqVenue]: + rows = [catalogue_row(raw) for raw in SAMPLE["gamma_markets"]] + poly = ListedVenue(venue="polymarket", eligible=[r.market_id for r in rows]) + poly.eligible = rows + us = ListedVenue(venue="polymarket_us", eligible=[]) # the US match is not combo-eligible there + kalshi = CollectionVenue(venue="kalshi", collections=[{"collection_ticker": "KXMVESPORTS", "associated_event_tickers": ["KXNFLGAME-26OCT22NECHI"]}]) + return {"kalshi": kalshi, "polymarket": poly, "polymarket_us": us} + + +LEGS = [ComboLeg(market_id=AL_FAYHA, outcome="yes"), ComboLeg(market_id=IRAN, outcome="yes")] + + +async def engine_with(tmp_path, adapters, *, warm: bool = True, down: bool = False) -> Engine: + engine = Engine(adapters, EngineConfig(journal_path=str(tmp_path / "engine.db"), in_doubt_timeout_s=0, + rfq_incoming_interval_s=None), + risk=RiskConfig(**RISK), matcher=matching(down)) + await engine.start() + warmer = ComboBoardWarmer(engine, Registry({"polymarket": GammaReader()}), pause_s=0) + if warm: + await warmer.build("polymarket") + engine.rfq_groups.labeler = warmer.labels_for + return engine + + +class CountingGamma(GammaReader): + def __init__(self): + self.asked: list[list[str]] = [] + + def fetch_markets_by_ids(self, market_ids): + self.asked.append(list(market_ids)) + return super().fetch_markets_by_ids(market_ids) + + +class TestGammaCache: + async def test_a_rebuild_reads_gamma_only_for_new_markets_and_keeps_prices_fresh(self): + rows = [catalogue_row(raw) for raw in SAMPLE["gamma_markets"]] + venue = ListedVenue(venue="polymarket", eligible=[]) + venue.eligible = rows + gamma = CountingGamma() + warmer = ComboBoardWarmer(FakeEngine({"polymarket": venue}), Registry({"polymarket": gamma}), pause_s=0) + await warmer.build("polymarket") + assert sum(len(a) for a in gamma.asked) == 4 + first = warmer.board("polymarket") + assert any(e.kind == "game" for e in first.events), "Gamma's types and the event's teams" + venue.eligible = rows + [ComboMarket(market_id="polymarket:1", venue="polymarket", title="Will it rain?", + tags=["weather"], info={"outcome_prices": ["0.4", "0.6"]})] + gamma.asked.clear() + await warmer.build("polymarket") + assert gamma.asked == [["polymarket:1"]], "only the new market" + again = warmer.board("polymarket") + assert [e.kind for e in again.events if e.kind == "game"] == [e.kind for e in first.events if e.kind == "game"] + cache = warmer.caches["polymarket"] + assert all("bestBid" not in m and "outcomePrices" not in m for m in cache.markets.values()), \ + "prices are never kept: each build takes the catalogue's" + + +class TestSkipReasons: + def test_the_board_names_al_fayha_by_its_short_name_and_questions_shortened(self): + rows = [catalogue_row(raw) for raw in SAMPLE["gamma_markets"]] + board = board_of("polymarket", polymarket_markets( + rows, {m["id"]: m for m in SAMPLE["gamma_markets"]}, {e["id"]: e for e in SAMPLE["gamma_events"]})) + labels = market_labels(board) + assert labels[AL_FAYHA] == "Al Fayha to win" and labels["polymarket:5441699"] == "Al Hazem to win" + assert labels[IRAN] == "U.S. invades Iran" + assert short_question("Will Al Fayha Saudi Club win on 2026-10-22?") == "Al Fayha Saudi Club wins" + assert short_question("Bitcoin above $100k on December 31?") == "Bitcoin above $100k" + + async def test_each_venue_names_every_leg_it_lacks(self, tmp_path): + engine = await engine_with(tmp_path, combo_venues()) + preview = await engine.preview_rfq_group(legs=LEGS) + skipped = {s.venue: s for s in preview.skipped} + assert [v.venue for v in preview.offered] == ["polymarket"] + kalshi, us = skipped["kalshi"], skipped["polymarket_us"] + assert kalshi.code == "missing_leg" + assert kalshi.reason == "no match for “Al Fayha to win”, “U.S. invades Iran”" + assert [(m.market_id, m.label, m.code) for m in kalshi.missing_legs] == [ + (AL_FAYHA, "Al Fayha to win", "missing_leg"), (IRAN, "U.S. invades Iran", "missing_leg")] + assert us.code == "missing_leg" and us.reason == "no match for “U.S. invades Iran”" + assert [m.market_id for m in us.missing_legs] == [IRAN] and us.leg == IRAN + await engine.stop() + + async def test_without_a_warm_board_the_catalogue_question_is_shortened(self, tmp_path): + engine = await engine_with(tmp_path, combo_venues(), warm=False) + preview = await engine.preview_rfq_group(legs=LEGS) + kalshi = next(s for s in preview.skipped if s.venue == "kalshi") + assert kalshi.reason == "no match for “Al Fayha Saudi Club wins”, “U.S. invades Iran”" + await engine.stop() + + async def test_a_matched_leg_the_venue_will_not_combo_is_not_a_missing_match(self, tmp_path): + global MATCHES + saved = dict(MATCHES) + MATCHES[(IRAN, "polymarket_us")] = {"id": "polymarket_us:iran", "venue": "polymarket_us", + "side_map": {"yes": "yes", "no": "no"}} + try: + engine = await engine_with(tmp_path, combo_venues()) + us = next(s for s in (await engine.preview_rfq_group(legs=LEGS)).skipped if s.venue == "polymarket_us") + finally: + MATCHES.clear() + MATCHES.update(saved) + assert us.code == "ineligible_leg" + assert us.reason == "not combo-eligible on Polymarket US: “Al Fayha to win”, “U.S. invades Iran”" + assert [m.mapped_id for m in us.missing_legs] == [AL_FAYHA_US, "polymarket_us:iran"] + await engine.stop() + + async def test_outside_kalshis_collections_and_matching_unavailable_are_their_own_reasons(self, tmp_path): + global MATCHES + saved = dict(MATCHES) + MATCHES[(AL_FAYHA, "kalshi")] = {"id": "kalshi:KXSPL-26OCT22HAZFAY-FAY", "venue": "kalshi", + "side_map": {"yes": "yes", "no": "no"}} + MATCHES[(IRAN, "kalshi")] = {"id": "kalshi:KXUSAIRAN-27-YES", "venue": "kalshi", + "side_map": {"yes": "yes", "no": "no"}} + try: + engine = await engine_with(tmp_path, combo_venues()) + kalshi = next(s for s in (await engine.preview_rfq_group(legs=LEGS)).skipped if s.venue == "kalshi") + finally: + MATCHES.clear() + MATCHES.update(saved) + assert kalshi.code == "outside_collections" + assert kalshi.reason.startswith("Kalshi combos are sports and cross-category only: “Al Fayha to win”") + await engine.stop() + (tmp_path / "down").mkdir() + down = await engine_with(tmp_path / "down", combo_venues(), down=True) + lost = {s.venue: s for s in (await down.preview_rfq_group(legs=LEGS)).skipped} + assert lost["kalshi"].code == "matching_error" + assert lost["kalshi"].reason == "matching unavailable for “Al Fayha to win”, “U.S. invades Iran”" + await down.stop() + + +class TestServe: + async def test_serve_warms_every_combo_venue_unless_told_not_to(self, tmp_path): + from synpath.server import serve + + for warm, home in ((True, "on"), (False, "off")): + venue = PagedVenue(pages=1) + venue.account = Account(venue="polymarket", name="paper") + (tmp_path / home).mkdir() + stack = await serve.build( + config={"risk": RISK, "combo_board": {"warm": warm, "refresh_interval_s": 5, "pause_s": 0}}, + journal=str(tmp_path / home / "serve.db"), control=str(tmp_path / home / "control.db"), + adapters={"polymarket": venue}, streams=False, home_dir=str(tmp_path / home / "home")) + loops = serve.background(stack) + try: + if warm: + assert stack.combo_boards.refresh_interval_s == 5 and "combo_boards" in loops + assert stack.engine.rfq_groups.labeler == stack.combo_boards.labels_for + else: + assert stack.combo_boards is None and "combo_boards" not in loops + finally: + for coro in loops.values(): + coro.close() + await serve.shutdown(stack) diff --git a/tests/test_core_streams.py b/tests/test_core_streams.py index 059cff4..aa92bdb 100644 --- a/tests/test_core_streams.py +++ b/tests/test_core_streams.py @@ -1,7 +1,7 @@ """Streams on the Rust core against the same streams in Python. -A stream hands each raw frame to the Rust core first (`handle_raw`) and to -its Python handler for whatever the core declines. These tests replay real +Each venue adapter owns native/Python selection inside handle_raw(). The +private native helper can decline a frame without mutating its state. These tests replay real recorded traffic through a stream both ways -- once as shipped, once with the core switched off -- and require the same events, the same books and the same pending checks after every frame. Dropped and corrupted frames make the @@ -72,7 +72,7 @@ def _run_now(coroutine): def polymarket_stream(rows, *, rust: bool): stream = PolymarketMarketStream() if not rust: - stream.handle_raw = lambda raw: None + stream._try_process_frame_with_rust = lambda raw: None recorder = Recorder(stream) for row in rows: if row["dir"] == "out" and "assets_ids" in row["msg"]: @@ -112,8 +112,8 @@ def replay_both(raw_frames, rows): rust, rust_rec = polymarket_stream(rows, rust=True) python, python_rec = polymarket_stream(rows, rust=False) for frame in raw_frames: - rust._dispatch(frame) - python._dispatch(frame) + rust._process_raw_frame_and_emit_events(frame) + python._process_raw_frame_and_emit_events(frame) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)], frame[:200] assert book_state(rust) == book_state(python) assert rust._unverified == python._unverified @@ -127,11 +127,11 @@ def test_the_core_handles_book_frames(self): rows = transcript("polymarket_market_transcript.jsonl") stream, _ = polymarket_stream(rows, rust=True) declined = [] - handle = stream.handle - stream.handle = lambda message: declined.append(message) or handle(message) + handle = stream._handle_decoded_message + stream._handle_decoded_message = lambda message: declined.append(message) or handle(message) all_frames = list(frames(rows)) for frame in all_frames: - stream._dispatch(frame) + stream._process_raw_frame_and_emit_events(frame) # Only the trades and the heartbeats reach Python. assert 0 < len(declined) < len(all_frames) / 10 @@ -163,7 +163,7 @@ def test_the_corrupted_replays_reach_the_gap_path(self): roll = rng.random() if roll < 0.08: continue - stream._dispatch(corrupt(frame, rng) if roll < 0.14 else frame) + stream._process_raw_frame_and_emit_events(corrupt(frame, rng) if roll < 0.14 else frame) gaps += len(recorder.tokens) assert gaps > 20 @@ -174,7 +174,7 @@ def test_a_frame_python_would_reject_is_left_to_python(self): token = next(iter(rust.tokens)) bad = json.dumps({"event_type": "book", "asset_id": token, "bids": [{"price": "abc", "size": "1"}], "asks": []}) for stream in (rust, python): - stream._dispatch(bad) + stream._process_raw_frame_and_emit_events(bad) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)] assert [e.state for e in drain(rust)] == [] assert book_state(rust) == book_state(python) @@ -192,7 +192,7 @@ def kalshi_stream(rows, *, rust: bool): ) stream = KalshiStream(KalshiCredentials(key_id="key-1", private_key_pem=pem, env="demo")) if not rust: - stream.handle_raw = lambda raw: None + stream._try_process_frame_with_rust = lambda raw: None commands: list = [] async def send_command(cmd, params): @@ -223,8 +223,8 @@ def replay_kalshi_both(raw_frames, rows): rust, rust_commands = kalshi_stream(rows, rust=True) python, python_commands = kalshi_stream(rows, rust=False) for frame in raw_frames: - rust._dispatch(frame) - python._dispatch(frame) + rust._process_raw_frame_and_emit_events(frame) + python._process_raw_frame_and_emit_events(frame) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)], frame[:200] assert kalshi_book_state(rust) == kalshi_book_state(python) assert {n: c.last_seq for n, c in rust.channels.items()} == {n: c.last_seq for n, c in python.channels.items()} @@ -242,11 +242,11 @@ def test_the_core_handles_book_frames(self): rows = transcript(self.ROWS) stream, _ = kalshi_stream(rows, rust=True) declined = [] - handle = stream.handle - stream.handle = lambda message: declined.append(message) or handle(message) + handle = stream._handle_decoded_message + stream._handle_decoded_message = lambda message: declined.append(message) or handle(message) frames_in = self.kalshi_frames(rows) for frame in frames_in: - stream._dispatch(frame) + stream._process_raw_frame_and_emit_events(frame) book_frames = [f for f in frames_in if '"orderbook_' in f] assert book_frames and not any(m.get("type", "").startswith("orderbook_") for m in declined) @@ -272,7 +272,7 @@ def test_the_drops_reach_the_gap_path(self): stream, commands = kalshi_stream(rows, rust=True) for frame in self.kalshi_frames(rows): if rng.random() > 0.05: - stream._dispatch(frame) + stream._process_raw_frame_and_emit_events(frame) asked += sum(1 for cmd, params in commands if params.get("action") == "get_snapshot") assert asked > 10 @@ -292,7 +292,7 @@ def stream(self, rng, *, rust: bool): stream = OpinionMarketStream(api_key="test-key-0123", catalog=None, resync_interval=None) if not rust: - stream.handle_raw = lambda raw: None + stream._try_process_frame_with_rust = lambda raw: None watched = Watched(market_id="opinion:8453", native="8453", yes_token=self.YES, no_token=self.NO, topic=None) seed = random.Random(rng.random()) for token in (self.YES, self.NO): @@ -328,8 +328,8 @@ def test_random_changes_match(self, seed): if i == 250: for stream in (rust, python): stream.books[self.NO].invalidate() - rust._dispatch(frame) - python._dispatch(frame) + rust._process_raw_frame_and_emit_events(frame) + python._process_raw_frame_and_emit_events(frame) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)], frame assert book_state(rust) == book_state(python) assert rust._pending == python._pending and rust._pending[self.YES] @@ -352,7 +352,7 @@ def stream(self, *, rust: bool): ) stream = PolymarketUSMarketStream(PolymarketUSCredentials(key_id="kid", secret_key=base64.b64encode(raw).decode())) if not rust: - stream.handle_raw = lambda raw: None + stream._try_process_frame_with_rust = lambda raw: None return stream def frames(self, rng, count=200): @@ -385,8 +385,8 @@ def test_random_market_data_matches(self, seed): rng = random.Random(seed) rust, python = self.stream(rust=True), self.stream(rust=False) for frame in self.frames(rng): - rust._dispatch(frame) - python._dispatch(frame) + rust._process_raw_frame_and_emit_events(frame) + python._process_raw_frame_and_emit_events(frame) assert [comparable(e) for e in drain(rust)] == [comparable(e) for e in drain(python)], frame assert book_state(rust) == book_state(python) assert rust.states == python.states diff --git a/tests/test_engine.py b/tests/test_engine.py index fa1cd9d..0296f49 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -33,6 +33,7 @@ from synpath.errors import NetworkError from synpath.trading.base import TradingExchange from synpath.trading.errors import OrderNotFound, OrderRejected, RiskRejected +from synpath.ws.base import LocalBook from synpath.trading.types import ( Account, Balance, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType, Position, PositionSide, Settlement, SettlementState, Side, TimeInForce, @@ -346,13 +347,17 @@ async def test_cancel_and_edit_carry_their_own_keys(self, journal_path): assert engine.open_orders() == [] await engine.stop() - async def test_cancelling_something_already_gone_is_not_an_error(self, journal_path): + async def test_cancelling_something_the_venue_no_longer_knows_is_not_confirmed(self, journal_path): + """Not found is not cancelled: the order may have filled. With no + record at the venue either, the last observed status stands.""" + from synpath.engine.submissions import CancelUnconfirmed venue = ScriptedVenue() engine = await engine_for(journal_path, venue) order = await engine.submit(request()) venue.orders.pop(order.id) - canceled = await engine.cancel(order.id) - assert canceled.status == OrderStatus.CANCELED + with pytest.raises(CancelUnconfirmed): + await engine.cancel(order.id) + assert (await engine.journal.order(order.venue, order.id)).status == OrderStatus.OPEN await engine.stop() @@ -379,11 +384,12 @@ async def test_each_rule_refuses_before_anything_is_sent(self, journal_path, rul assert events == ["risk.rejected"] await engine.stop() - async def test_the_price_collar_uses_the_stored_fair_value(self, journal_path): + async def test_the_price_collar_uses_the_book_midpoint(self, journal_path): venue = ScriptedVenue() engine = await engine_for(journal_path, venue) - engine.risk.config = engine.risk.config.model_copy(update={"price_collar": D("0.05")}) - await engine.fair_values.set("kalshi:default", "kalshi:KX-A", D("0.60"), source="mid") + engine.order_risk_checks.config = engine.order_risk_checks.config.model_copy(update={"price_collar": D("0.05")}) + engine.set_book("kalshi:KX-A", LocalBook()) + engine.books["kalshi:KX-A"].replace([(D("0.59"), D("10"))], [(D("0.61"), D("10"))]) with pytest.raises(RiskRejected) as caught: await engine.submit(request(price=D("0.40"))) assert caught.value.rule == "price_collar" @@ -419,7 +425,7 @@ async def test_a_paused_book_may_cancel_but_not_open(self, journal_path): async def test_the_risk_configuration_is_versioned(self, journal_path): venue = ScriptedVenue() engine = await engine_for(journal_path, venue) - first = engine.risk.config_version + first = engine.order_risk_checks.config_version second = await engine.set_risk(RiskConfig(max_order_contracts=D("2"), price_collar=None, duplicate_window_ms=0)) assert second > first with pytest.raises(RiskRejected): @@ -542,7 +548,8 @@ def test_three_levels_and_the_firm_total(self): ledger.apply_fill(Fill(id=f"F{i}", order_id="V1", venue="kalshi", account=ACCOUNT, market_id=market, side=Side.BUY, price=D("0.50"), quantity=D("10"), fee=D("0.10"), timestamp=i), book=book) - marks = {("kalshi:default", "kalshi:KX-A"): D("0.55"), ("kalshi:default", "kalshi:KX-B"): D("0.45")} + marks = {("kalshi:default", "alpha", "kalshi:KX-A"): D("0.55"), ("kalshi:default", "beta", "kalshi:KX-A"): D("0.55"), + ("kalshi:default", "alpha", "kalshi:KX-B"): D("0.45")} assert set(ledger.rollup("market", marks)) == {"kalshi:KX-A", "kalshi:KX-B"} assert set(ledger.rollup("book", marks)) == {"alpha", "beta"} assert set(ledger.rollup("account", marks)) == {"kalshi:default"} @@ -602,6 +609,7 @@ async def test_a_week_of_paper_trading_on_an_accelerated_clock(self, journal_pat max_orders_per_minute=None, closing_soon_s=None), accounts={"kalshi": Account(venue="kalshi", name="paper")}, clock=lambda: clock["t"]) await engine.start() + engine.set_book("kalshi:KX-A", LocalBook()) random.seed(7) price = D("0.50") submitted = canceled = 0 @@ -610,8 +618,7 @@ async def test_a_week_of_paper_trading_on_an_accelerated_clock(self, journal_pat move = D(random.choice(["-0.02", "-0.01", "0", "0.01", "0.02"])) price = min(max(price + move, D("0.05")), D("0.95")) paper.set_book("kalshi:KX-A", bids=[(price - D("0.01"), D("200"))], asks=[(price + D("0.01"), D("200"))]) - for market_id, book in (("kalshi:KX-A", None),): - await engine.fair_values.set("kalshi:paper", market_id, price, source="mid", persist=False) + engine.books["kalshi:KX-A"].replace([(price - D("0.01"), D("200"))], [(price + D("0.01"), D("200"))]) if minute % 30 == 0: # Half the hours the order fills, half it is still resting and # gets pulled: both paths run, over a week of them. @@ -632,7 +639,7 @@ async def test_a_week_of_paper_trading_on_an_accelerated_clock(self, journal_pat taker_side=Side.SELL if side == Side.BUY else Side.BUY): await engine.on_fill(fill) assert submitted == 7 * 24 - total = engine.ledger.total(engine.fair_values.marks()) + total = engine.ledger.total(engine.marks()) # Maker fills on this fee model cost nothing, which is the venues' # own schedule; the taker path is checked on its own above. assert total.volume > 0 and total.fees == D("0") and total.unrealized is not None diff --git a/tests/test_engine_orders.py b/tests/test_engine_orders.py index 27fba94..fe534ba 100644 --- a/tests/test_engine_orders.py +++ b/tests/test_engine_orders.py @@ -106,11 +106,11 @@ async def test_a_sell_stop_watches_the_bid_and_fires_once(self, setup): book(paper, engine, bids=[(D("0.42"), D("500"))], asks=[(D("0.44"), D("500"))]) await feed_book(engine, paper=paper) - assert engine.orders.get(parent.id).state == "waiting", "0.42 is above the stop" + assert engine.managed_order_controller.get(parent.id).state == "waiting", "0.42 is above the stop" book(paper, engine, bids=[(D("0.39"), D("500"))], asks=[(D("0.41"), D("500"))]) await feed_book(engine, paper=paper) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state in ("working", "done") and managed.triggered_at is not None assert managed.filled == D("10"), "the child took the bid" assert [f.side for f in paper.fills] == [Side.SELL] @@ -126,7 +126,7 @@ async def test_a_buy_stop_watches_the_ask(self, setup): parent = await engine.submit(request(type=OrderType.STOP_LIMIT, stop_price=D("0.50"), price=D("0.52"))) book(paper, engine, bids=[(D("0.48"), D("100"))], asks=[(D("0.51"), D("100"))]) await feed_book(engine, paper=paper) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "done" and managed.filled == D("10") assert paper.fills[0].price == D("0.51"), "a stop-limit takes what is inside its limit" @@ -136,16 +136,16 @@ async def test_the_trigger_source_can_be_the_last_trade(self, setup): parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), trigger_source="last")) await feed_book(engine, paper=paper) - assert engine.orders.get(parent.id).state == "waiting", "a wide book must not fire a last-trade stop" + assert engine.managed_order_controller.get(parent.id).state == "waiting", "a wide book must not fire a last-trade stop" await engine.on_trade({"market_id": SYM, "price": "0.38", "quantity": "5"}) - assert engine.orders.get(parent.id).state in ("working", "done") + assert engine.managed_order_controller.get(parent.id).state in ("working", "done") async def test_a_trailing_stop_follows_one_way_only(self, setup): engine, paper, clock = setup book(paper, engine, bids=[(D("0.50"), D("500"))], asks=[(D("0.52"), D("500"))]) parent = await engine.submit(request(side=Side.SELL, type=OrderType.TRAILING_STOP, stop_price=D("0.47"), params={"trail": "0.03"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) await feed_book(engine, paper=paper) assert managed.stop_price == D("0.47") @@ -166,7 +166,7 @@ async def test_a_stop_with_no_book_to_price_against_is_rejected(self, setup): parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), trigger_source="last")) await engine.on_trade({"market_id": SYM, "price": "0.35", "quantity": "1"}) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "rejected" and "no book" in managed.detail assert managed.as_order().status == OrderStatus.REJECTED and paper.fills == [] @@ -188,16 +188,16 @@ async def record(req): assert child.type == OrderType.MARKET and child.time_in_force == TimeInForce.IOC assert child.price == D("0.99"), "the ask plus 0.05, kept inside the price range" assert child.quantity == D("10") and child.tags["parent"] == parent.id - assert engine.orders.get(parent.id).as_order().status == OrderStatus.CLOSED + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.CLOSED async def test_an_ioc_remainder_ends_the_stop_with_the_unfilled_recorded(self, setup): engine, paper, clock = setup book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"))) - book(paper, engine, bids=[(D("0.39"), D("4")), (D("0.20"), D("500"))], asks=[(D("0.41"), D("500"))]) + book(paper, engine, bids=[(D("0.39"), D("4"))], asks=[(D("0.41"), D("500"))]) await feed_book(engine, paper=paper) - managed = engine.orders.get(parent.id) - assert managed.filled == D("4"), "0.20 is past the protection" + managed = engine.managed_order_controller.get(parent.id) + assert managed.filled == D("4"), "only four contracts were available" assert not managed.live and "6 unfilled" in managed.detail assert managed.as_order().remaining == D("6") @@ -205,8 +205,8 @@ async def test_a_waiting_stop_can_be_cancelled(self, setup): engine, paper, clock = setup book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"))) - await engine.orders.cancel(parent.id) - managed = engine.orders.get(parent.id) + await engine.managed_order_controller.cancel(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.as_order().status == OrderStatus.CANCELED and managed.children == [] async def test_a_gtd_stop_market_expires_while_armed_and_sends_nothing(self, setup): @@ -215,11 +215,11 @@ async def test_a_gtd_stop_market_expires_while_armed_and_sends_nothing(self, set expires = int((clock() + 60) * 1000) parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), time_in_force=TimeInForce.GTD, expires_at=expires)) - await engine.orders.on_timer() - assert engine.orders.get(parent.id).state == "waiting" + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(parent.id).state == "waiting" clock.tick(61) - await engine.orders.on_timer() - managed = engine.orders.get(parent.id) + await engine.managed_order_controller.on_timer() + managed = engine.managed_order_controller.get(parent.id) assert managed.as_order().status == OrderStatus.EXPIRED and managed.children == [] and paper.fills == [] async def test_a_day_stop_market_stays_armed_until_the_session_end(self, setup): @@ -227,7 +227,7 @@ async def test_a_day_stop_market_stays_armed_until_the_session_end(self, setup): book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), time_in_force=TimeInForce.DAY)) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.request.expires_at == session_expiry(clock(), session_end=engine.config.session_end, timezone_name=engine.config.session_timezone) @@ -239,8 +239,8 @@ async def test_a_triggered_stop_market_is_not_cut_short_by_its_expiry(self, setu book(paper, engine, bids=[(D("0.39"), D("500"))], asks=[(D("0.41"), D("500"))]) await feed_book(engine, paper=paper) clock.tick(61) - await engine.orders.on_timer() - assert engine.orders.get(parent.id).as_order().status == OrderStatus.CLOSED + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.CLOSED async def test_a_stop_limit_child_carries_the_orders_time_in_force(self, setup): engine, paper, clock = setup @@ -261,22 +261,58 @@ async def record(req): [child] = sent assert (child.type, child.price, child.time_in_force, child.expires_at) == ( OrderType.LIMIT, D("0.38"), TimeInForce.GTD, expires) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.as_order().status == OrderStatus.TRIGGERED and len(managed.live_children) == 1 clock.tick(61) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.as_order().status == OrderStatus.EXPIRED assert await paper.fetch_open_orders() == [], "the resting child was pulled" + async def test_a_day_stop_limit_child_rests_until_the_same_session_end(self, setup): + engine, paper, clock = setup + sent: list[OrderRequest] = [] + create = paper.create_order + + async def record(req): + sent.append(req) + return await create(req) + paper.create_order = record + + book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) + parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_LIMIT, stop_price=D("0.40"), + price=D("0.38"), time_in_force=TimeInForce.DAY)) + managed = engine.managed_order_controller.get(parent.id) + session_end = session_expiry(clock(), session_end=engine.config.session_end, + timezone_name=engine.config.session_timezone) + assert (managed.request.time_in_force, managed.request.expires_at) == (TimeInForce.GTD, session_end), \ + "the session end is persisted as an absolute expiry" + book(paper, engine, bids=[(D("0.39"), D("0"))], asks=[(D("0.50"), D("500"))]) + await feed_book(engine, paper=paper) + [child] = sent + assert (child.type, child.time_in_force, child.expires_at) == (OrderType.LIMIT, TimeInForce.GTD, session_end) + + clock.t = session_end / 1000 + 1 + await engine.managed_order_controller.on_timer() + assert managed.as_order().status == OrderStatus.EXPIRED + assert await paper.fetch_open_orders() == [], "the resting child was canceled at the session end" + + async def test_a_stop_already_met_by_the_book_the_engine_holds_triggers_at_submit(self, setup): + engine, paper, clock = setup + book(paper, engine, bids=[(D("0.39"), D("500"))], asks=[(D("0.41"), D("500"))]) + parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"))) + await paper.deliver() + managed = engine.managed_order_controller.get(parent.id) + assert managed.triggered_at is not None and managed.filled == D("10"), "no further book event was needed" + async def test_a_gtd_stop_limit_expires_before_it_triggers(self, setup): engine, paper, clock = setup book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) parent = await engine.submit(request(type=OrderType.STOP_LIMIT, stop_price=D("0.50"), price=D("0.52"), time_in_force=TimeInForce.GTD, expires_at=int((clock() + 5) * 1000))) clock.tick(6) - await engine.orders.on_timer() - assert engine.orders.get(parent.id).as_order().status == OrderStatus.EXPIRED + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.EXPIRED async def test_a_stop_limit_cancelled_after_the_trigger_pulls_its_child(self, setup): engine, paper, clock = setup @@ -285,17 +321,32 @@ async def test_a_stop_limit_cancelled_after_the_trigger_pulls_its_child(self, se price=D("0.38"))) book(paper, engine, bids=[(D("0.39"), D("0"))], asks=[(D("0.50"), D("500"))]) await feed_book(engine, paper=paper) - await engine.orders.cancel(parent.id) - assert engine.orders.get(parent.id).as_order().status == OrderStatus.CANCELED + await engine.managed_order_controller.cancel(parent.id) + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.CANCELED assert await paper.fetch_open_orders() == [] + async def test_an_older_immediate_stop_limit_is_still_restored(self, setup): + """Accepted before a stop's time in force became the whole order's: + refused for new orders, never for one a journal already holds.""" + from pydantic import ValidationError + from synpath.engine.orders.stop import StopLimit + + engine, paper, clock = setup + parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_LIMIT, stop_price=D("0.40"), + price=D("0.38"))) + snapshot = engine.managed_order_controller.get(parent.id).snapshot() + snapshot["request"]["time_in_force"] = "ioc" + with pytest.raises(ValidationError): + OrderRequest.model_validate(snapshot["request"]) + assert StopLimit.from_snapshot(snapshot).request.time_in_force == TimeInForce.IOC + async def test_the_trigger_source_survives_a_restart_of_the_parent(self, setup): from synpath.engine.orders.stop import StopMarket engine, paper, clock = setup parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), trigger_source="mid")) - snapshot = engine.orders.get(parent.id).snapshot() + snapshot = engine.managed_order_controller.get(parent.id).snapshot() assert snapshot["request"]["trigger_source"] == "mid" assert StopMarket.from_snapshot(snapshot).trigger_source == "mid" @@ -318,16 +369,16 @@ def test_trigger_source_is_a_field_with_three_values(self): with pytest.raises(ValueError, match="stop orders"): request(price=D("0.5"), trigger_source="last") - @pytest.mark.parametrize("tif", [TimeInForce.IOC, TimeInForce.FOK]) - def test_a_stop_market_is_armed_gtc_gtd_or_day_only(self, tif): - with pytest.raises(ValueError, match="always ioc"): - request(type=OrderType.STOP_MARKET, stop_price=D("0.5"), time_in_force=tif) - - def test_a_stop_limit_takes_gtc_gtd_ioc_or_fok(self): - for tif in (TimeInForce.GTC, TimeInForce.IOC, TimeInForce.FOK): - request(type=OrderType.STOP_LIMIT, stop_price=D("0.5"), price=D("0.5"), time_in_force=tif) - with pytest.raises(ValueError, match="gtd with expires_at"): - request(type=OrderType.STOP_LIMIT, stop_price=D("0.5"), price=D("0.5"), time_in_force=TimeInForce.DAY) + @pytest.mark.parametrize("kind", [OrderType.STOP_MARKET, OrderType.STOP_LIMIT]) + def test_a_stop_lives_gtc_gtd_or_day_as_one_order(self, kind): + """One time in force for the whole order, as IBKR and FIX use it.""" + stop = dict(type=kind, stop_price=D("0.5"), price=D("0.5") if kind == OrderType.STOP_LIMIT else None) + for tif in (TimeInForce.GTC, TimeInForce.DAY): + request(**stop, time_in_force=tif) + request(**stop, time_in_force=TimeInForce.GTD, expires_at=1) + for tif in (TimeInForce.IOC, TimeInForce.FOK): + with pytest.raises(ValueError, match=f"how long the whole order lives.*not {tif.value}"): + request(**stop, time_in_force=tif) def test_a_stop_limit_price_is_on_the_far_side_of_its_stop(self): request(side=Side.SELL, type=OrderType.STOP_LIMIT, stop_price=D("0.40"), price=D("0.38")) @@ -348,22 +399,22 @@ async def test_it_shows_a_slice_and_reloads_after_the_delay(self, setup): book(paper, engine, bids=[(D("0.40"), D("0"))], asks=[(D("0.45"), D("100"))]) parent = await engine.submit(request(quantity=D("30"), price=D("0.40"), type=OrderType.ICEBERG, params={"display": "10", "reload_delay_s": 5})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert len(managed.children) == 1 and managed.children[0].quantity == D("10") assert await paper.fetch_open_orders() and (await paper.fetch_open_orders())[0].quantity == D("10") await tape(paper, price=D("0.40"), quantity=D("10"), taker_side=Side.SELL) assert managed.filled == D("10") and len(managed.live_children) == 0 - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert len(managed.children) == 1, "the reload waits for its delay" clock.tick(6) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert len(managed.children) == 2 and managed.reloads == 1 await tape(paper, price=D("0.40"), quantity=D("10"), taker_side=Side.SELL) clock.tick(6) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await tape(paper, price=D("0.40"), quantity=D("10"), taker_side=Side.SELL) assert managed.filled == D("30") and managed.state == "done" @@ -389,7 +440,7 @@ async def test_one_leg_filling_cancels_the_other(self, setup): {"type": "limit", "side": "buy", "price": "0.29"}]}, )) await paper.deliver() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert len(managed.live_children) == 2 first = managed.children[0] await tape(paper, price=D("0.31"), quantity=D("10"), taker_side=Side.SELL) @@ -404,7 +455,7 @@ async def test_a_partial_fill_resizes_the_other_leg(self, setup): params={"legs": [{"type": "limit", "side": "buy", "price": "0.31"}, {"type": "limit", "side": "buy", "price": "0.29"}]}, )) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) await tape(paper, price=D("0.31"), quantity=D("4"), taker_side=Side.SELL) assert managed.filled == D("4") others = [o for o in await paper.fetch_open_orders() if o.price == D("0.29")] @@ -419,14 +470,14 @@ async def test_a_bracket_protects_what_the_entry_fills(self, setup): "take_profit": {"type": "limit", "price": "0.55"}, "stop_loss": {"type": "stop_market", "stop_price": "0.35"}}, )) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.entered == D("0") and len(managed.live_children) == 1 await tape(paper, price=D("0.41"), quantity=D("6"), taker_side=Side.SELL) assert managed.entered == D("6") and managed.protected == D("6") protection = [c for c in managed.children if managed.leg_of.get(c.order_id) in ("take_profit", "stop_loss")] assert [c.quantity for c in protection] == [D("6"), D("6")] - assert engine.orders.get(protection[1].order_id).kind == "stop_market", "the stop-loss is its own managed order" + assert engine.managed_order_controller.get(protection[1].order_id).kind == "stop_market", "the stop-loss is its own managed order" await tape(paper, price=D("0.41"), quantity=D("4"), taker_side=Side.SELL) assert managed.entered == D("10") and managed.protected == D("10") @@ -438,7 +489,7 @@ async def test_a_bracket_whose_entry_never_fills_ends_with_it(self, setup): quantity=D("10"), type=OrderType.BRACKET, params={"entry": {"type": "limit", "price": "0.20"}, "take_profit": {"type": "limit", "price": "0.55"}}, )) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) entry = managed.leg_children("entry")[0] await engine.cancel(entry.order_id) assert managed.state == "canceled" and "entry" in managed.detail @@ -454,15 +505,15 @@ async def test_it_slices_across_the_window(self, setup): book(paper, engine, bids=[(D("0.40"), D("100"))], asks=[(D("0.42"), D("100"))]) parent = await engine.submit(request(quantity=D("10"), price=D("0.41"), type=OrderType.TWAP, params={"window_s": 100, "slices": 5, "style": "limit"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.sent_slices == 1 and managed.children[0].quantity == D("2") clock.tick(21) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.sent_slices == 2 and len(managed.children) == 2 clock.tick(60) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.sent_slices == 5, "a late tick catches up rather than skipping" async def test_the_window_ends_by_taking_the_rest(self, setup): @@ -472,10 +523,10 @@ async def test_the_window_ends_by_taking_the_rest(self, setup): params={"window_s": 60, "slices": 3, "style": "taker", "limit": "0.45", "finish": "complete"})) await paper.deliver() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled > 0, "a taker slice crosses at once" clock.tick(61) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await paper.deliver() assert managed.filled == D("10") and managed.state == "done" @@ -485,9 +536,9 @@ async def test_finish_stop_leaves_the_rest_undone(self, setup): parent = await engine.submit(request(quantity=D("10"), price=D("0.10"), type=OrderType.TWAP, params={"window_s": 30, "slices": 3, "style": "limit", "limit": "0.10", "finish": "stop"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) clock.tick(31) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.state in ("canceled", "done") and managed.filled == D("0") assert await paper.fetch_open_orders() == [] @@ -503,7 +554,7 @@ async def test_it_follows_the_touch_within_its_bounds(self, setup): parent = await engine.submit(request(quantity=D("10"), price=D("0.40"), type=OrderType.PEG, params={"reference": "near", "offset": "0", "min_stay_s": 0, "max_price": "0.45", "level_cap": 3})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.resting_price == D("0.40") book(paper, engine, bids=[(D("0.41"), D("100"))], asks=[(D("0.44"), D("100"))]) @@ -520,7 +571,7 @@ async def test_the_minimum_stay_and_the_level_cap_hold_it_still(self, setup): book(paper, engine, bids=[(D("0.40"), D("100"))], asks=[(D("0.44"), D("100"))]) parent = await engine.submit(request(quantity=D("10"), price=D("0.40"), type=OrderType.PEG, params={"reference": "near", "min_stay_s": 10, "level_cap": 2})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) book(paper, engine, bids=[(D("0.41"), D("100"))], asks=[(D("0.44"), D("100"))]) await feed_book(engine, paper=paper) assert managed.resting_price == D("0.40"), "it has not sat long enough to move" @@ -549,7 +600,7 @@ async def test_a_walked_market_order_stops_at_the_slippage_bound(self, setup): parent = await engine.submit(request(quantity=D("20"), type=OrderType.MARKET, params={"walk": True, "max_slippage": "0.03"})) await paper.deliver() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled == D("6"), "0.42 and 0.44 are inside 0.45; 0.60 is not" assert managed.state in ("done", "canceled") and "unfilled" in managed.detail @@ -559,17 +610,17 @@ async def test_a_smart_taker_clips_over_time(self, setup): parent = await engine.submit(request(quantity=D("10"), type=OrderType.SMART_TAKER, params={"clip": "4", "interval_s": 5, "limit": "0.45"})) await paper.deliver() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled == D("4") and managed.clips == 1 - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.clips == 1, "the interval has not passed" clock.tick(6) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await paper.deliver() assert managed.clips == 2 and managed.filled == D("8") clock.tick(6) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await paper.deliver() assert managed.filled == D("10") and managed.state == "done" @@ -578,11 +629,11 @@ async def test_a_smart_taker_waits_rather_than_paying_up(self, setup): book(paper, engine, bids=[], asks=[(D("0.60"), D("100"))]) parent = await engine.submit(request(quantity=D("10"), type=OrderType.SMART_TAKER, params={"clip": "5", "interval_s": 1, "limit": "0.45"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled == D("0") and managed.state == "working" book(paper, engine, bids=[], asks=[(D("0.44"), D("100"))]) clock.tick(2) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() await paper.deliver() assert managed.filled == D("5") @@ -620,7 +671,7 @@ async def test_a_restart_brings_the_parents_back(self, setup, tmp_path): params={"trail": "0.03"})) book(paper, engine, bids=[(D("0.60"), D("500"))], asks=[(D("0.62"), D("500"))]) await feed_book(engine, paper=paper) - assert engine.orders.get(parent.id).stop_price == D("0.57") + assert engine.managed_order_controller.get(parent.id).stop_price == D("0.57") path = engine.config.journal_path await engine.journal.close() @@ -634,7 +685,7 @@ async def test_a_restart_brings_the_parents_back(self, setup, tmp_path): recovery = await restarted.start() try: assert recovery.managed == 1 - revived = restarted.orders.get(parent.id) + revived = restarted.managed_order_controller.get(parent.id) assert revived.stop_price == D("0.57"), "the trail resumes where the market left it" restarted.set_book(SYM, paper.books[SYM]) book(paper, restarted, bids=[(D("0.56"), D("500"))], asks=[(D("0.58"), D("500"))]) @@ -650,7 +701,7 @@ async def test_a_halt_stands_every_parent_down(self, setup): await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.20"))) result = await engine.halt("drill", policy="cancel") assert result["managed"] == 2 - assert all(not p.live for p in engine.orders.parents.values()) + assert all(not p.live for p in engine.managed_order_controller.parents.values()) assert await paper.fetch_open_orders() == [] async def test_cancelling_a_parent_pulls_its_children(self, setup): @@ -662,7 +713,7 @@ async def test_cancelling_a_parent_pulls_its_children(self, setup): canceled = await engine.cancel(parent.id) assert canceled.status == OrderStatus.CANCELED assert await paper.fetch_open_orders() == [] - assert engine.orders.get(parent.id).state == "canceled" + assert engine.managed_order_controller.get(parent.id).state == "canceled" async def test_parents_are_not_reported_as_ghosts(self, setup): """A parent is not an order at the venue, so reconciliation must not @@ -674,11 +725,11 @@ async def test_parents_are_not_reported_as_ghosts(self, setup): parent = await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.10"))) report = (await Reconciler(engine).run("kalshi"))[0] assert [d.key for d in report.ghosts] == [] - assert engine.orders.get(parent.id).state == "waiting" + assert engine.managed_order_controller.get(parent.id).state == "waiting" class TestOrderFields: - """`reduce_only`, `post_only` and `expires_at` on engine-held orders: honoured or refused, never ignored.""" + """The outcome and action, `post_only` and `expires_at` on engine-held orders: honoured or refused, never ignored.""" @staticmethod def record(paper: PaperVenue) -> list[OrderRequest]: @@ -691,25 +742,28 @@ async def create_order(req): paper.create_order = create_order # type: ignore[method-assign] return sent - async def test_a_reduce_only_stop_sends_reduce_only_children(self, setup): + async def test_a_sell_yes_stop_sends_sell_yes_children(self, setup): engine, paper, clock = setup sent = self.record(paper) book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) - await engine.submit(request(side=Side.SELL, type=OrderType.STOP_MARKET, stop_price=D("0.40"), reduce_only=True)) + await engine.submit(request(side=Side.SELL, outcome="yes", action="sell", type=OrderType.STOP_MARKET, + stop_price=D("0.40"))) book(paper, engine, bids=[(D("0.39"), D("500"))], asks=[(D("0.41"), D("500"))]) await feed_book(engine, paper=paper) - assert sent and all(r.reduce_only for r in sent), "a protective stop must not open a position" + assert sent and all(r.leg == ("yes", "sell") for r in sent), "a protective stop sells what is held, never buys NO" - async def test_bracket_legs_inherit_reduce_only_unless_they_say_otherwise(self, setup): + async def test_bracket_legs_inherit_the_outcome_and_action_unless_they_say_otherwise(self, setup): from synpath.engine.orders.oco import leg_request engine, paper, clock = setup book(paper, engine, bids=[(D("0.45"), D("500"))], asks=[(D("0.47"), D("500"))]) - parent = await engine.submit(request(side=Side.SELL, type=OrderType.OCO, reduce_only=True, params={"legs": [ - {"side": "sell", "type": "limit", "price": "0.60"}, {"side": "sell", "type": "limit", "price": "0.65"}]})) - managed = engine.orders.get(parent.id) - assert leg_request({"type": "limit", "price": "0.6"}, parent=managed).reduce_only is True - assert leg_request({"type": "limit", "price": "0.6", "reduce_only": False}, parent=managed).reduce_only is False + parent = await engine.submit(request(side=Side.SELL, outcome="yes", action="sell", type=OrderType.OCO, + params={"legs": [{"side": "sell", "type": "limit", "price": "0.60"}, + {"side": "sell", "type": "limit", "price": "0.65"}]})) + managed = engine.managed_order_controller.get(parent.id) + assert leg_request({"type": "limit", "price": "0.6"}, parent=managed).leg == ("yes", "sell") + assert leg_request({"type": "limit", "price": "0.6", "outcome": "no", "action": "buy"}, + parent=managed).leg == ("no", "buy") async def test_post_only_reaches_the_slices_of_types_that_rest(self, setup): engine, paper, clock = setup @@ -739,12 +793,12 @@ async def test_a_parent_ends_at_its_expiry_and_pulls_its_children(self, setup): expires = int((clock() + 60) * 1000) parent = await engine.submit(request(quantity=D("30"), price=D("0.40"), type=OrderType.ICEBERG, expires_at=expires, params={"display": "10"})) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert len(managed.live_children) == 1 - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.live, "not yet" clock.tick(61) - await engine.orders.on_timer() + await engine.managed_order_controller.on_timer() assert managed.state == "expired" and not managed.live_children assert managed.as_order().status == OrderStatus.EXPIRED assert await paper.fetch_open_orders() == [] diff --git a/tests/test_engine_orders_demo.py b/tests/test_engine_orders_demo.py index 5b052da..4f7aba7 100644 --- a/tests/test_engine_orders_demo.py +++ b/tests/test_engine_orders_demo.py @@ -141,7 +141,7 @@ async def test_an_iceberg_shows_only_its_slice(self, engine, kalshi, market): )) assert parent.status == OrderStatus.TRIGGERED and parent.held_by.value == "engine" - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) mine = {c.order_id for c in managed.children} assert len(mine) == 1, "one slice at a time" @@ -170,7 +170,7 @@ async def test_a_stop_watches_the_real_book_and_does_not_fire_early(self, engine stop_price=(bid / 2).quantize(D("0.01")), book="demo-stop", )) await engine.on_book({"instrument_id": f"{market}:yes"}) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "waiting", "the stop is far from the touch" assert [o for o in await kalshi.fetch_open_orders() if o.market_id == market] == [] @@ -189,7 +189,7 @@ async def test_a_twap_sends_its_slices_to_the_venue(self, engine, kalshi, market instrument_id=f"{market}:yes", side=Side.BUY, quantity=D("2"), price=D("0.02"), type=OrderType.TWAP, params={"window_s": 4, "slices": 2, "style": "limit", "limit": "0.02"}, book="demo-twap", )) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.sent_slices == 1 assert await wait_for(lambda: len(managed.children) >= 2, timeout=10) @@ -214,7 +214,7 @@ async def test_a_parent_survives_a_restart_against_the_venue(self, tmp_path, kal instrument_id=f"{market}:yes", side=Side.BUY, quantity=D("2"), price=D("0.02"), type=OrderType.ICEBERG, params={"display": "1"}, book="demo-restart", )) - child_ids = [c.order_id for c in first.orders.get(parent.id).children] + child_ids = [c.order_id for c in first.managed_order_controller.get(parent.id).children] await first.journal.close() second = Engine({"kalshi": kalshi}, EngineConfig(journal_path=path, managed_tick_s=0.05), @@ -223,7 +223,7 @@ async def test_a_parent_survives_a_restart_against_the_venue(self, tmp_path, kal recovery = await second.start() try: assert recovery.managed == 1 - revived = second.orders.get(parent.id) + revived = second.managed_order_controller.get(parent.id) assert revived is not None and revived.state == "working" assert [c.order_id for c in revived.children] == child_ids, "the same child, not a new one" async def child_is_live() -> bool: @@ -243,7 +243,7 @@ async def test_a_halt_stands_parents_and_children_down(self, engine, kalshi, mar )) result = await engine.halt("demo drill", policy="cancel") assert result["managed"] >= 1 - assert all(not p.live for p in engine.orders.parents.values()) + assert all(not p.live for p in engine.managed_order_controller.parents.values()) assert await wait_for(lambda: no_orders_left(kalshi, market)) async def test_a_day_order_carries_a_venue_expiry(self, engine, kalshi, market): diff --git a/tests/test_engine_rfq.py b/tests/test_engine_rfq.py new file mode 100644 index 0000000..88fc7b2 --- /dev/null +++ b/tests/test_engine_rfq.py @@ -0,0 +1,680 @@ +"""The engine's RFQ and combo desk, offline (P3 and P4 of docs/rfq-combo.md). + +Everything runs against `PaperRfqVenue`, a paper venue with configurable +makers, on SQLite and (with SYNPATH_TEST_POSTGRES_DSN) PostgreSQL. Covered: +accept to order to fill to position; accept by amount; the worst-price and +risk refusals that send nothing; halt cancelling every RFQ and quote; a +crash between the intent and the call and between the call and its result, +on both sides; the side guard; a combo as its own instrument; the quoter +flows with expiry and confirm through risk; partial fills, a lapsed last +look; and the order the events arrive in. +""" +from __future__ import annotations + +import asyncio +import os +import time +import uuid +from decimal import Decimal as D + +import pytest + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.engine.alerts import Alerts +from synpath.engine.paper import PaperMaker, PaperRfqVenue +from synpath.engine.reconcile import Reconciler +from synpath.engine.storage_types import IntentState +from synpath.errors import BadRequest +from synpath.trading.errors import InvalidOrder, RiskRejected +from synpath.trading.types import ( + AcceptQuote, ComboLeg, MakerQuote, OrderRequest, OrderStatus, OrderType, PositionSide, QuoteStatus, RfqStatus, + Side, +) + +pytestmark = pytest.mark.anyio + +VENUE = "polymarket_us" +MARKET = "polymarket_us:tec-nba-a" +RISK = dict(price_collar=None, duplicate_window_ms=0, closing_soon_s=None) + + +class Crash(BaseException): + """The process dies: nothing in the engine catches it.""" + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(params=["sqlite", "postgresql"]) +async def config(request, tmp_path): + """An `EngineConfig` factory on each backend; PostgreSQL only with a disposable DSN.""" + base = dict(in_doubt_timeout_s=0, rfq_poll_interval_s=0.01, rfq_incoming_interval_s=0) + if request.param == "sqlite": + path = str(tmp_path / "engine.db") + yield lambda **kw: EngineConfig(journal_path=path, **{**base, **kw}) + return + dsn = os.environ.get("SYNPATH_TEST_POSTGRES_DSN") + if not dsn: + pytest.skip("set SYNPATH_TEST_POSTGRES_DSN to a disposable PostgreSQL database") + psycopg = pytest.importorskip("psycopg") + schema = "synpath_rfq_" + uuid.uuid4().hex + try: + yield lambda **kw: EngineConfig(journal_path=dsn, storage_backend="postgresql", storage_schema=schema, + **{**base, **kw}) + finally: + from psycopg import sql + async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as connection: + await connection.execute(sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(sql.Identifier(schema))) + + +def paper(**kw) -> PaperRfqVenue: + return PaperRfqVenue(venue=VENUE, **kw) + + +async def started(config, venue: PaperRfqVenue, *, lost_after_s: float = 60.0, **risk) -> Engine: + engine = Engine({VENUE: venue}, config(lost_after_s=lost_after_s), risk=RiskConfig(**{**RISK, **risk})) + await engine.start() + return engine + + +async def crashed(engine: Engine, venue: PaperRfqVenue) -> None: + """The process dies: the journal closes, nothing else runs.""" + venue.quote_listeners.clear() + await engine.journal.close() + + +def events(sub, *prefixes: str) -> list: + out = [] + while not sub.queue.empty(): + event = sub.queue.get_nowait() + if not prefixes or event.kind.startswith(prefixes): + out.append(event) + return out + + +def kinds(sub, *prefixes: str) -> list[str]: + return [e.kind for e in events(sub, *prefixes)] + + +async def first_quote(venue: PaperRfqVenue, rfq_id: str): + return (await venue.fetch_quotes(rfq_id))[0] + + +def accept_of(quote, **kw) -> AcceptQuote: + base = dict(quote_id=quote.id, rfq_id=quote.rfq_id, outcome="yes", action="buy", worst_price=D("0.50"), + book="alpha") + return AcceptQuote(**{**base, **kw}) + + +async def execute(engine: Engine, venue: PaperRfqVenue) -> None: + """The maker's last look, then the execution, each seen by the follower.""" + for _ in range(2): + venue.advance() + await engine.rfq.tick() + + +async def accept_intents(engine: Engine, operation: str = "accept_quote"): + return [i for i in await engine.journal.intents() if i.operation == operation] + + +# -- taker ------------------------------------------------------------------------- + + +class TestAccept: + async def test_accept_becomes_an_order_a_fill_and_a_position_with_events_in_order(self, config): + venue = paper() + engine = await started(config, venue) + sub = engine.bus.subscribe() + rfq = await engine.create_rfq(MARKET, quantity=D("10"), book="alpha") + assert rfq.status == RfqStatus.OPEN and f"{VENUE}:{rfq.id}" in engine.rfq.rfqs + await engine.rfq.tick() + quote = await first_quote(venue, rfq.id) + + result = await engine.accept_quote(accept_of(quote)) + assert result.status == QuoteStatus.ACCEPTED, "accept returns at once" + intent = (await accept_intents(engine))[0] + assert intent.client_order_id == f"rfq-accept:{VENUE}:{quote.id}" + assert intent.state == IntentState.SENDING and intent.request["type"] == "rfq_taker" + assert engine.positions() == [] + + await execute(engine, venue) + intent = await engine.journal.intent(intent.client_order_id) + assert intent.state == IntentState.SENT and intent.order_id + order = await engine.journal.order(VENUE, intent.order_id) + assert order.type == OrderType.RFQ_TAKER and order.status == OrderStatus.CLOSED + assert (order.outcome, order.action, order.book) == ("yes", "buy", "alpha") + assert order.tags["quote_id"] == quote.id + [position] = engine.positions() + assert (position.market_id, position.side, position.contracts, position.entry_price) == \ + (MARKET, PositionSide.LONG, D("10"), D("0.45")) + assert position.info["book"] == "alpha" + fills = await engine.journal.fills() + assert len(fills) == 1 and fills[0].outcome == "yes" and fills[0].action == "buy" + + assert kinds(sub, "rfq.", "quote.", "order.", "fill.") == [ + "rfq.created", "quote.received", "quote.accepted", "quote.confirmed", "quote.executed", + "order.accepted", "order.closed", "fill.booked", "rfq.closed", + ] + assert f"{VENUE}:{rfq.id}" not in engine.rfq.rfqs + await engine.stop() + + async def test_wait_follows_the_quote_to_its_end(self, config): + venue = paper(auto_advance=True) + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + quote = await venue.fetch_quote((await venue.fetch_quotes(rfq.id))[0].id) + result = await engine.accept_quote(accept_of(quote), wait=True, timeout_s=5) + assert result.status == QuoteStatus.EXECUTED + assert engine.positions()[0].contracts == D("10") + await engine.stop() + + async def test_accept_by_amount_sizes_contracts_down(self, config): + venue = paper(makers=[PaperMaker(quantity=D("100"))]) + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("100")) + quote = await first_quote(venue, rfq.id) + await engine.accept_quote(accept_of(quote, amount=D("9.20"))) + # 9.20 / 0.45 = 20.4, down to whole contracts on Polymarket US. + assert venue.accepted[quote.id]["quantity"] == D("20") + intent = (await accept_intents(engine))[0] + assert D(intent.request["quantity"]) == D("20") and intent.request["params"]["amount"] == "9.20" + await execute(engine, venue) + assert engine.positions()[0].contracts == D("20") + await engine.stop() + + async def test_an_amount_below_one_contract_sends_nothing(self, config): + venue = paper() + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + quote = await first_quote(venue, rfq.id) + with pytest.raises(InvalidOrder, match="below the minimum"): + await engine.accept_quote(accept_of(quote, amount=D("0.20"))) + assert "accept_quote" not in venue.calls and await accept_intents(engine) == [] + await engine.stop() + + async def test_a_quote_worse_than_the_worst_price_is_refused_before_sending(self, config): + venue = paper() + engine = await started(config, venue) + sub = engine.bus.subscribe("risk") + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + quote = await first_quote(venue, rfq.id) + with pytest.raises(RiskRejected) as refused: + await engine.accept_quote(accept_of(quote, worst_price=D("0.44"))) + assert refused.value.rule == "worst_price" + # Selling YES takes the bid (0.40): a floor of 0.41 refuses it too. + with pytest.raises(RiskRejected): + await engine.accept_quote(accept_of(quote, action="sell", worst_price=D("0.41"))) + assert "accept_quote" not in venue.calls and await accept_intents(engine) == [] + [first, second] = events(sub) + assert first.payload["rule"] == "worst_price" and first.payload["operation"] == "rfq_taker" + await engine.stop() + + async def test_a_risk_rule_refuses_the_accept_and_sends_nothing(self, config): + venue = paper() + engine = await started(config, venue, max_position_contracts=D("5")) + sub = engine.bus.subscribe("risk") + rfq = await engine.create_rfq(MARKET, quantity=D("10")) # an RFQ commits nothing: allowed + quote = await first_quote(venue, rfq.id) + with pytest.raises(RiskRejected) as refused: + await engine.accept_quote(accept_of(quote)) + assert refused.value.rule == "max_position" + assert "accept_quote" not in venue.calls and await accept_intents(engine) == [] + assert events(sub)[0].payload["operation"] == "rfq_taker" + await engine.stop() + + async def test_the_kill_switch_refuses_an_accept_and_an_rfq(self, config): + venue = paper() + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + quote = await first_quote(venue, rfq.id) + await engine.halt("operator", policy="hold") # hold leaves the RFQ up + assert venue.rfqs[rfq.id].status == RfqStatus.OPEN + with pytest.raises(RiskRejected) as refused: + await engine.accept_quote(accept_of(quote)) + assert refused.value.rule == "kill_switch" + with pytest.raises(RiskRejected): + await engine.create_rfq(MARKET, quantity=D("1")) + assert venue.calls == ["create_rfq"] + await engine.stop() + + async def test_a_partial_fill_books_what_filled(self, config): + venue = paper(makers=[PaperMaker(fill_ratio=D("0.5"))]) + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + await engine.accept_quote(accept_of(await first_quote(venue, rfq.id))) + await execute(engine, venue) + order = (await engine.journal.open_orders()) or None + assert order is None, "the IOC remainder was cancelled" + intent = (await accept_intents(engine))[0] + stored = await engine.journal.order(VENUE, intent.order_id) + assert (stored.status, stored.filled) == (OrderStatus.CANCELED, D("5")) + assert engine.positions()[0].contracts == D("5") + await engine.stop() + + async def test_a_maker_letting_the_last_look_lapse_fails_the_accept(self, config): + venue = paper(makers=[PaperMaker(confirms=False)]) + engine = await started(config, venue) + sub = engine.bus.subscribe("quote") + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + await engine.accept_quote(accept_of(await first_quote(venue, rfq.id))) + venue.advance() + await engine.rfq.tick() + intent = (await accept_intents(engine))[0] + assert intent.state == IntentState.FAILED and "expired" in intent.detail + failed = [e for e in events(sub) if e.kind == "quote.failed"] + assert failed and failed[0].payload["reason"] == "expired" + assert engine.positions() == [] + await engine.stop() + + async def test_a_stream_moves_the_accept_without_the_follower(self, config): + venue = paper() + engine = await started(config, venue) + sub = engine.bus.subscribe() + rfq = await engine.create_rfq(MARKET, quantity=D("10"), book="alpha") + await engine.accept_quote(accept_of(await first_quote(venue, rfq.id))) + for _ in range(2): + venue.advance() + await venue.deliver() + assert engine.positions()[0].contracts == D("10") + assert kinds(sub, "quote.", "order.", "fill.")[-6:] == [ + "quote.accepted", "quote.confirmed", "quote.executed", "order.accepted", "order.closed", "fill.booked", + ] + await engine.stop() + + async def test_a_fill_polled_before_its_order_is_adopted_is_still_attributed(self, config): + venue = paper() + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("10"), book="alpha") + await engine.accept_quote(accept_of(await first_quote(venue, rfq.id))) + venue.advance() + venue.advance() # executed at the venue, unseen by the engine + await engine.poll(VENUE) # the fill poll finds a fill of an unknown order + [fill] = await engine.journal.fills() + assert fill.outcome == "yes" + assert engine.positions()[0].info["book"] == "alpha" + assert (await accept_intents(engine))[0].state == IntentState.SENT + await engine.stop() + + async def test_a_retried_accept_is_not_sent_twice(self, config): + venue = paper() + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + quote = await first_quote(venue, rfq.id) + await engine.accept_quote(accept_of(quote)) + again = await engine.accept_quote(accept_of(quote)) + assert again.status == QuoteStatus.ACCEPTED and venue.calls.count("accept_quote") == 1 + await engine.stop() + + async def test_an_rfq_type_cannot_be_submitted_as_an_order(self, config): + engine = await started(config, paper()) + with pytest.raises(BadRequest, match="accept_quote"): + await engine.submit(OrderRequest(market_id=MARKET, side=Side.BUY, quantity=D("1"), price=D("0.5"), + type=OrderType.RFQ_TAKER)) + await engine.stop() + + +class TestSideGuard: + async def test_the_venue_side_is_booked_and_the_mismatch_alerted(self, config): + venue = paper(makers=[PaperMaker(report_side=Side.SELL)]) + engine = await started(config, venue) + alerts = Alerts(engine.bus) + sub = engine.bus.subscribe("rfq.side_mismatch") + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + await engine.accept_quote(accept_of(await first_quote(venue, rfq.id), worst_price=None)) + await execute(engine, venue) + mismatches = events(sub) + assert len(mismatches) == 1, "published once, though seen at the accept, the quote and the order" + assert (mismatches[0].payload["expected"], mismatches[0].payload["reported"]) == ("buy", "sell") + assert any(a.kind == "rfq_side" and a.severity == "critical" for a in alerts.raised) + [position] = engine.positions() + assert (position.side, position.contracts) == (PositionSide.SHORT, D("10")) + order = await engine.journal.order(VENUE, (await accept_intents(engine))[0].order_id) + assert order.side == Side.SELL and order.outcome is None and order.action is None + await engine.stop() + + +class TestRecovery: + async def test_a_crash_between_the_intent_and_the_call_is_rejected_on_restart(self, config): + venue = paper() + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + quote = await first_quote(venue, rfq.id) + real = venue.accept_quote + + async def dies(accept): + raise Crash() + + venue.accept_quote = dies + with pytest.raises(Crash): + await engine.accept_quote(accept_of(quote)) + assert (await accept_intents(engine))[0].state == IntentState.SENDING + await crashed(engine, venue) + + venue.accept_quote = real + restarted = await started(config, venue, lost_after_s=0) + [intent] = await accept_intents(restarted) + assert intent.state == IntentState.REJECTED and "never took the accept" in intent.detail + assert venue.quotes[quote.id].status == QuoteStatus.OPEN and "accept_quote" not in venue.calls + # Nothing traded, so the same accept may be made again. + result = await restarted.accept_quote(accept_of(quote)) + assert result.status == QuoteStatus.ACCEPTED and venue.calls.count("accept_quote") == 1 + await execute(restarted, venue) + assert restarted.positions()[0].contracts == D("10") + await restarted.stop() + + async def test_a_crash_between_the_call_and_its_result_is_adopted_on_restart(self, config): + venue = paper() + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("10"), book="alpha") + quote = await first_quote(venue, rfq.id) + real = venue.accept_quote + + async def answered_then_died(accept): + await real(accept) + raise Crash() + + venue.accept_quote = answered_then_died + with pytest.raises(Crash): + await engine.accept_quote(accept_of(quote)) + await crashed(engine, venue) + + venue.accept_quote = real + restarted = await started(config, venue, lost_after_s=0) + [intent] = await accept_intents(restarted) + assert intent.state == IntentState.SENDING, "accepted at the venue: followed, not swept" + again = await restarted.accept_quote(accept_of(quote)) + assert again.status == QuoteStatus.ACCEPTED and venue.calls.count("accept_quote") == 1 + await execute(restarted, venue) + intent = await restarted.journal.intent(intent.client_order_id) + assert intent.state == IntentState.SENT + [position] = restarted.positions() + assert (position.contracts, position.info["book"]) == (D("10"), "alpha") + await restarted.stop() + + async def test_an_executed_quote_found_at_start_is_adopted_by_recover(self, config): + venue = paper() + engine = await started(config, venue) + rfq = await engine.create_rfq(MARKET, quantity=D("10")) + await engine.accept_quote(accept_of(await first_quote(venue, rfq.id))) + await crashed(engine, venue) + venue.advance() + venue.advance() + restarted = Engine({VENUE: venue}, config(), risk=RiskConfig(**RISK)) + recovery = await restarted.start() + assert recovery.adopted == 1 and recovery.in_doubt == 1 + assert restarted.positions()[0].contracts == D("10") + await restarted.stop() + + +class TestCombo: + async def test_a_combo_position_lives_on_its_own_instrument(self, config): + venue = paper() + engine = await started(config, venue) + legs = [ComboLeg(market_id="polymarket_us:tec-a", outcome="yes"), + ComboLeg(market_id="polymarket_us:tec-b", outcome="no")] + combo = await engine.create_combo(legs) + rfq = await engine.create_rfq(combo.market_id, quantity=D("4"), combo=combo, book="parlay") + assert rfq.combo_id == combo.market_id and rfq.market_id is None + await engine.accept_quote(accept_of(await first_quote(venue, rfq.id), book="parlay")) + await execute(engine, venue) + [position] = engine.positions() + assert position.market_id == combo.market_id and position.contracts == D("4") + assert [l["market_id"] for l in position.info["combo"]["legs"]] == ["polymarket_us:tec-a", "polymarket_us:tec-b"] + report = (await Reconciler(engine).run())[0] + assert report.positions == [] and report.checked_positions == 1 + await engine.stop() + + restarted = await started(config, venue) + assert restarted.positions()[0].info["combo"]["legs"][1]["outcome"] == "no" + assert (await restarted.rfq.combo(combo.market_id)).market_id == combo.market_id + await restarted.stop() + + +# -- quoter ------------------------------------------------------------------------ + + +def maker_quote(rfq, **kw) -> MakerQuote: + base = dict(rfq_id=rfq.id, bid=D("0.40"), ask=D("0.46"), book="mm") + return MakerQuote(**{**base, **kw}) + + +class TestQuoter: + async def test_incoming_quote_confirm_order_fill_position(self, config): + venue = paper(maker_fill_ratio=D("0.5")) + engine = await started(config, venue) + sub = engine.bus.subscribe("rfq", "quote", "order", "fill") + incoming = venue.add_incoming_rfq(MARKET, quantity=D("10")) + await engine.rfq.tick() + assert f"{VENUE}:{incoming.id}" in engine.rfq.incoming + + quote = await engine.send_quote(maker_quote(incoming, expires_at=int(time.time() * 1000) + 60_000)) + assert quote.mine and quote.expires_at is not None + assert venue.quotes[quote.id].expires_at is None, "the engine holds the expiry" + venue.requester_accepts(quote.id, outcome="yes", action="buy") + await engine.rfq.tick() + confirmed = await engine.confirm_quote(quote.id) + assert confirmed.status == QuoteStatus.CONFIRMED + [intent] = await accept_intents(engine, "confirm_quote") + assert intent.state == IntentState.SENDING and intent.request["side"] == "sell" + assert D(intent.request["price"]) == D("0.46") and intent.request["type"] == "rfq_maker" + + venue.advance() + await engine.rfq.tick() + assert (await engine.journal.intent(intent.client_order_id)).state == IntentState.SENT + [position] = engine.positions() + assert (position.side, position.contracts, position.info["book"]) == (PositionSide.SHORT, D("5"), "mm") + assert kinds(sub) == [ + "rfq.incoming", "quote.sent", "quote.accepted", "quote.confirmed", "quote.executed", + "order.accepted", "order.canceled", "fill.booked", + ] + assert engine.rfq.maker_quotes == {} + await engine.stop() + + async def test_confirm_goes_through_risk_and_the_last_look_lapses(self, config): + now = [1_000.0] + venue = paper(clock=lambda: now[0]) + engine = await started(config, venue, max_position_contracts=D("5")) + incoming = venue.add_incoming_rfq(MARKET, quantity=D("10")) + quote = await engine.send_quote(maker_quote(incoming)) # an offer: the position rule waits + venue.requester_accepts(quote.id) + with pytest.raises(RiskRejected) as refused: + await engine.confirm_quote(quote.id) + assert refused.value.rule == "max_position" + assert "confirm_quote" not in venue.calls and await accept_intents(engine, "confirm_quote") == [] + now[0] += 10 + venue.advance() + assert venue.quotes[quote.id].status == QuoteStatus.EXPIRED + await engine.rfq.tick() + assert engine.rfq.maker_quotes == {} + await engine.stop() + + async def test_sending_a_quote_checks_the_size_limits(self, config): + venue = paper() + engine = await started(config, venue, max_order_contracts=D("5")) + incoming = venue.add_incoming_rfq(MARKET, quantity=D("10")) + with pytest.raises(RiskRejected) as refused: + await engine.send_quote(maker_quote(incoming)) + assert refused.value.rule == "max_order_contracts" + await engine.set_risk(RiskConfig(**RISK, max_order_notional=D("4"))) + with pytest.raises(RiskRejected) as notional: + await engine.send_quote(maker_quote(incoming)) # selling YES at 0.46 holds 10 NO at 0.54 + assert notional.value.rule == "max_order_notional" + assert "send_quote" not in venue.calls + await engine.stop() + + async def test_a_quote_expires_at_its_expiry(self, config): + venue = paper() + engine = await started(config, venue) + sub = engine.bus.subscribe("quote.expired") + incoming = venue.add_incoming_rfq(MARKET, quantity=D("10")) + quote = await engine.send_quote(maker_quote(incoming, expires_at=int(time.time() * 1000) + 50)) + await asyncio.sleep(0.08) + await engine.rfq.tick() + assert venue.quotes[quote.id].status == QuoteStatus.CANCELED + assert engine.rfq.maker_quotes == {} and len(events(sub)) == 1 + cancel = await engine.journal.intent(f"rfq-quote-cancel:{VENUE}:{quote.id}") + assert cancel.state == IntentState.SETTLED and cancel.detail == "expired" + await engine.stop() + + async def test_replace_swaps_the_quote(self, config): + venue = paper() + engine = await started(config, venue) + sub = engine.bus.subscribe("quote.replaced") + incoming = venue.add_incoming_rfq(MARKET, quantity=D("10")) + old = await engine.send_quote(maker_quote(incoming)) + new = await engine.replace_quote(old.id, maker_quote(incoming, bid=D("0.41"), ask=D("0.45"))) + assert new.id != old.id and venue.quotes[old.id].status == QuoteStatus.CANCELED + assert list(engine.rfq.maker_quotes) == [f"{VENUE}:{new.id}"] + assert events(sub)[0].payload["replaced"] == old.id + await engine.cancel_quote(new.id) + assert venue.quotes[new.id].status == QuoteStatus.CANCELED and engine.rfq.maker_quotes == {} + await engine.stop() + + async def test_a_crash_between_the_confirm_intent_and_the_call_lets_the_last_look_lapse(self, config): + venue = paper() + engine = await started(config, venue) + incoming = venue.add_incoming_rfq(MARKET, quantity=D("10")) + quote = await engine.send_quote(maker_quote(incoming)) + venue.requester_accepts(quote.id) + real = venue.confirm_quote + + async def dies(q): + raise Crash() + + venue.confirm_quote = dies + with pytest.raises(Crash): + await engine.confirm_quote(quote.id) + await crashed(engine, venue) + venue.confirm_quote = real + restarted = await started(config, venue, lost_after_s=0) + [intent] = await accept_intents(restarted, "confirm_quote") + assert intent.state == IntentState.REJECTED and "last look lapses" in intent.detail + assert f"{VENUE}:{quote.id}" in restarted.rfq.maker_quotes, "the working set survived the restart" + await restarted.stop() + + async def test_a_crash_after_the_confirm_is_adopted_on_restart(self, config): + venue = paper() + engine = await started(config, venue) + incoming = venue.add_incoming_rfq(MARKET, quantity=D("10")) + quote = await engine.send_quote(maker_quote(incoming)) + venue.requester_accepts(quote.id, outcome="no", action="buy") # the requester sells YES at our bid + real = venue.confirm_quote + + async def confirmed_then_died(q): + await real(q) + raise Crash() + + venue.confirm_quote = confirmed_then_died + with pytest.raises(Crash): + await engine.confirm_quote(quote.id) + await crashed(engine, venue) + venue.confirm_quote = real + restarted = await started(config, venue, lost_after_s=0) + assert (await accept_intents(restarted, "confirm_quote"))[0].state == IntentState.SENDING + venue.advance() + await restarted.rfq.tick() + [position] = restarted.positions() + assert (position.side, position.contracts, position.entry_price) == (PositionSide.LONG, D("10"), D("0.40")) + assert venue.calls.count("confirm_quote") == 1 + await restarted.stop() + + +class TestIncomingPush: + """Gap 1: a venue that pushes incoming RFQs reaches the quoting callback + at once, not at the next 2 s poll.""" + + async def test_a_pushed_rfq_reaches_the_callback_without_a_poll(self, config): + venue = paper(stream_incoming=True) + polls = [] + original = venue.fetch_incoming_rfqs + + async def counted(**kw): + polls.append(kw) + return await original(**kw) + + venue.fetch_incoming_rfqs = counted + engine = Engine({VENUE: venue}, config(rfq_incoming_interval_s=2.0), risk=RiskConfig(**RISK)) + await engine.start() + assert engine.rfq.pushed == {VENUE} + sub = engine.bus.subscribe("rfq", "quote") + timings: dict[str, float] = {} + order: list[str] = [] + + async def quoter(rfq): + timings["callback"] = time.perf_counter() + order.append("callback") + assert f"{VENUE}:{rfq.id}" in engine.rfq.incoming, "recorded before the callback" + assert [e.kind for e in events(sub)] == ["rfq.incoming"], "published before the callback" + await engine.send_quote(maker_quote(rfq)) + timings["quoted"] = time.perf_counter() + + engine.rfq.on_incoming_rfq(quoter) + pushed_at = time.perf_counter() + rfq = await venue.push_incoming_rfq(MARKET, quantity=D("10")) + to_callback_ms = (timings["callback"] - pushed_at) * 1000 + to_quote_ms = (timings["quoted"] - pushed_at) * 1000 + print(f"pushed RFQ -> callback {to_callback_ms:.3f} ms, -> quote sent {to_quote_ms:.3f} ms") + assert to_callback_ms < 50, "no poll delay: far below the 400 ms window" + assert to_quote_ms < 400 + assert polls == [], "nothing polled" + assert [q.rfq_id for q in venue.quotes.values() if q.mine] == [rfq.id] + assert order == ["callback"] + # The loop skips the pushed venue's poll; starting the stream reads once. + await engine.rfq.tick() + await engine.rfq.tick() + assert venue.stream_started and len(polls) == 1 + assert await engine.rfq.on_incoming(VENUE, rfq) is False, "a repeat is not heard twice" + await engine.stop() + + async def test_a_venue_without_a_stream_is_polled_and_calls_back_too(self, config): + venue = paper() + engine = await started(config, venue) + assert engine.rfq.pushed == set() + heard = [] + unsubscribe = engine.rfq.on_incoming_rfq(lambda rfq: heard.append(rfq.id)) + incoming = venue.add_incoming_rfq(MARKET, quantity=D("10")) + await engine.rfq.tick() + assert heard == [incoming.id] + unsubscribe() + venue.add_incoming_rfq(MARKET, quantity=D("5")) + await engine.rfq.tick() + assert heard == [incoming.id] + await engine.stop() + + async def test_a_failing_callback_does_not_stop_the_others(self, config): + venue = paper(stream_incoming=True) + engine = await started(config, venue) + heard = [] + + def broken(rfq): + raise RuntimeError("strategy bug") + + engine.rfq.on_incoming_rfq(broken) + engine.rfq.on_incoming_rfq(lambda rfq: heard.append(rfq.id)) + rfq = await venue.push_incoming_rfq(MARKET, quantity=D("10")) + assert heard == [rfq.id] + await engine.stop() + + +class TestHalt: + async def test_halt_cancels_every_rfq_and_quote_on_every_venue(self, config): + venue = paper() + engine = await started(config, venue) + sub = engine.bus.subscribe("rfq.closed", "quote.canceled") + ours = [await engine.create_rfq(MARKET, quantity=D("5")) for _ in range(2)] + stray = await venue.create_rfq(MARKET, quantity=D("3")) # made outside this engine + incoming = venue.add_incoming_rfq(MARKET, quantity=D("10")) + mine = await engine.send_quote(maker_quote(incoming)) + result = await engine.halt("operator") + assert result["rfq"] == {"rfqs": {VENUE: 3}, "quotes": {VENUE: 1}} + assert all(venue.rfqs[r.id].status == RfqStatus.CLOSED for r in [*ours, stray]) + assert venue.quotes[mine.id].status == QuoteStatus.CANCELED + assert engine.rfq.rfqs == {} and engine.rfq.maker_quotes == {} + got = events(sub) + assert sorted(e.kind for e in got).count("rfq.closed") == 3 + assert any(e.kind == "quote.canceled" and e.payload.get("reason") == "halt" for e in got) + with pytest.raises(RiskRejected): + await engine.send_quote(maker_quote(incoming)) + await engine.stop() diff --git a/tests/test_engine_rfq_group.py b/tests/test_engine_rfq_group.py new file mode 100644 index 0000000..e7cd92c --- /dev/null +++ b/tests/test_engine_rfq_group.py @@ -0,0 +1,632 @@ +"""Cross-venue RFQ groups in the engine, offline (docs/rfq-combo.md, "Cross-venue RFQ"). + +Several `PaperRfqVenue`s under real venue names, each with its own makers +and fee schedule, one journal, and the hosted matching service mocked at +the HTTP layer (`httpx.MockTransport` under the real `match_market`). +Covered: a bucket fanned out with a flipped member; a cross-venue combo +mapped by matching, with one venue skipped for a missing leg and another for +an ineligible one, and Kalshi-style collections; a plain market with +`cross_venue`; ranking after fees, by quantity and by amount; accept +cancelling the others (one left to expire); double-accept refusal, in memory +and in the journal; an accept that fails and is retried; a crash between the +cancels and the accept; restart recovery with re-seeding; and the halt. +""" +from __future__ import annotations + +from decimal import Decimal as D +from typing import Any + +import httpx +import pytest + +from synpath import ids +from synpath.bucket import Bucket, BucketMember +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.engine.paper import PaperMaker, PaperRfqVenue, no_fees, quadratic_fee +from synpath.engine.rfq_group import hosted_matcher +from synpath.engine.storage_types import IntentState +from synpath.errors import BadRequest +from synpath.trading.errors import RiskRejected +from synpath.trading.types import AcceptQuote, ComboLeg, ComboMarket, OrderStatus, QuoteStatus, Side +from synpath.types import Page + +pytestmark = pytest.mark.anyio + +RISK = dict(price_collar=None, duplicate_window_ms=0, closing_soon_s=None) +K_A, K_B = "kalshi:KXA-26-X", "kalshi:KXB-26-Y" +P_A, P_B = "polymarket:111", "polymarket:222" +U_A, U_B = "polymarket_us:aaa", "polymarket_us:bbb" +L_A, L_B = "limitless:la", "limitless:lb" + + +class Crash(BaseException): + """The process dies: nothing in the engine catches it.""" + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(autouse=True) +def no_saved_key(monkeypatch, tmp_path): + monkeypatch.delenv("SYNPATH_API_KEY", raising=False) + monkeypatch.setenv("SYNPATH_CREDENTIALS_FILE", str(tmp_path / "no-credentials.json")) + + +# -- venues ------------------------------------------------------------------- + +class CollectionVenue(PaperRfqVenue): + """A Kalshi-like paper venue: combos are built in collections.""" + + def __init__(self, *, collections: list[dict[str, Any]] = (), **kw: Any): + super().__init__(**kw) + self.collections = list(collections) + self.reseeded: list[list[str]] = [] + + async def fetch_combo_collections(self, *, status: str | None = "open", event_id: str | None = None, + series_id: str | None = None, limit: int | None = None, + cursor: str | None = None) -> Page: + event = ids.split(event_id)[1] if event_id else None + return Page([r for r in self.collections if event is None or event in r["associated_event_tickers"]]) + + def restore_rfqs(self, rfqs, quotes=None) -> None: + self.reseeded.append(sorted(r.id for r in rfqs)) + + +class ListedVenue(PaperRfqVenue): + """A Polymarket-like paper venue: a list of combo-eligible markets.""" + + def __init__(self, *, eligible: list[str] = (), **kw: Any): + super().__init__(**kw) + self.eligible = [ComboMarket(market_id=m, venue=self.venue, position_ids={"yes": f"{m}-Y", "no": f"{m}-N"}) + for m in eligible] + + async def fetch_combo_eligible_markets(self, *, event_id=None, tag=None, limit=None, cursor=None) -> Page: + return Page(self.eligible) + + +def flat_fee(per_contract: str): + def fee(market_id, price, quantity, liquidity): + return D(per_contract) * quantity + return fee + + +def venues(**over: Any) -> dict[str, PaperRfqVenue]: + """Three venues, three fee schedules, three makers' prices on YES: + Kalshi asks 0.43 (quadratic 7% fee), Polymarket 0.455 (free, no + requester cancel), Polymarket US 0.44 (2c a contract).""" + kalshi = CollectionVenue( + venue="kalshi", fees=quadratic_fee(), auto_advance=True, + makers=over.pop("kalshi_makers", [PaperMaker(name="k1", bid=D("0.40"), ask=D("0.43"))]), + collections=[{"collection_ticker": "KXMVE-1", "associated_event_tickers": ["KXA-26", "KXB-26"], + "size_min": 2, "size_max": 10}]) + poly = ListedVenue(venue="polymarket", fees=no_fees, auto_advance=True, eligible=[P_A, P_B], + makers=over.pop("poly_makers", [PaperMaker(name="p1", bid=D("0.42"), ask=D("0.455"))])) + poly.rfq_requester_cancel = False + us = ListedVenue(venue="polymarket_us", fees=flat_fee("0.02"), auto_advance=True, eligible=[U_A], + makers=over.pop("us_makers", [PaperMaker(name="u1", bid=D("0.39"), ask=D("0.44"))])) + out = {"kalshi": kalshi, "polymarket": poly, "polymarket_us": us} + if over.pop("limitless", False): + out["limitless"] = ListedVenue(venue="limitless", eligible=[L_A], makers=[PaperMaker(name="l1")]) + return out + + +# -- the matching service, mocked over HTTP ------------------------------------- + +MATCHES = { + (K_A, "polymarket"): {"id": P_A, "venue": "polymarket", "side_map": {"yes": "yes", "no": "no"}}, + (K_B, "polymarket"): {"id": P_B, "venue": "polymarket", "side_map": {"yes": "no", "no": "yes"}}, + (K_A, "polymarket_us"): {"id": U_A, "venue": "polymarket_us", "side_map": {"yes": "yes", "no": "no"}}, + (K_B, "polymarket_us"): None, # US lists nothing like KXB: a missing leg + (K_A, "limitless"): {"id": L_A, "venue": "limitless", "side_map": {"yes": "yes", "no": "no"}}, + (K_B, "limitless"): {"id": L_B, "venue": "limitless", "side_map": {"yes": "yes", "no": "no"}}, +} + + +def matching(seen: list | None = None): + def handler(request: httpx.Request) -> httpx.Response: + anchor, venue = request.url.params["id"], request.url.params.get("venue") + if seen is not None: + seen.append((anchor, venue, request.headers.get("authorization"))) + if (anchor, venue) not in MATCHES: + return httpx.Response(404, json={"detail": "market not found"}) + return httpx.Response(200, json={"anchor": anchor, "event_id": "evt", "matched": MATCHES[(anchor, venue)]}) + + return hosted_matcher(client=httpx.Client(transport=httpx.MockTransport(handler)), api_key="spk_test") + + +async def started(tmp_path, adapters: dict[str, PaperRfqVenue], *, seen: list | None = None, **risk: Any) -> Engine: + engine = Engine(adapters, EngineConfig(journal_path=str(tmp_path / "engine.db"), in_doubt_timeout_s=0, + rfq_poll_interval_s=0.01, rfq_incoming_interval_s=None), + risk=RiskConfig(**{**RISK, **risk}), matcher=matching(seen)) + await engine.start() + return engine + + +async def crashed(engine: Engine, adapters: dict[str, PaperRfqVenue]) -> None: + for venue in adapters.values(): + venue.quote_listeners.clear() + await engine.journal.close() + + +def by_venue(group) -> dict[str, Any]: + return {m.venue: m for m in group.members} + + +def quote_on(group, venue: str): + return next(q for q in group.quotes if q.venue == venue) + + +def drain(sub) -> list: + out = [] + while not sub.queue.empty(): + out.append(sub.queue.get_nowait()) + return out + + +# --------------------------------------------------------------------------- + +class TestBucket: + async def test_fans_out_to_each_member_and_turns_a_flipped_members_quote_over(self, tmp_path): + adapters = venues(poly_makers=[PaperMaker(name="p1", bid=D("0.60"), ask=D("0.65"))]) + engine = await started(tmp_path, adapters) + bucket = await engine.save_bucket(Bucket(book="alpha", name="A", members=[ + BucketMember(market_id=K_A), BucketMember(market_id=P_A, flip=True), + BucketMember(market_id=U_A, enabled=False)])) + group = await engine.create_rfq_group(bucket.market_id, quantity=D("10"), book="alpha") + + assert group.status == "open" + members = by_venue(group) + assert set(members) == {"kalshi", "polymarket"} + assert members["polymarket"].flip and not members["kalshi"].flip + assert [(s.venue, s.code) for s in group.skipped] == [("polymarket_us", "member_disabled")] + flipped = quote_on(group, "polymarket") + # Buying the bucket's YES is buying the flipped member's NO: its bid 0.60 is the bucket's 0.40. + assert flipped.raw_price == D("0.60") and flipped.price == D("0.40") and flipped.fee == 0 + assert quote_on(group, "kalshi").price == D("0.43") + assert group.best().venue == "polymarket" + + done = await engine.accept_group_quote(group.id, flipped.quote_id, venue=flipped.venue, worst_price=D("0.45"), wait=True) + assert done.quote.status == QuoteStatus.EXECUTED and done.group.status == "executed" + order = (await engine.journal.order_by_client_id(done.client_order_id)) + assert order.side == Side.SELL and order.market_id == P_A and order.price == D("0.60") + assert adapters["kalshi"].rfqs[members["kalshi"].rfq_id].status.value == "closed" + await engine.stop() + + +class TestCombo: + async def test_maps_legs_by_matching_and_skips_a_missing_and_an_ineligible_leg(self, tmp_path): + seen: list = [] + adapters = venues(limitless=True) + engine = await started(tmp_path, adapters, seen=seen) + legs = [ComboLeg(market_id=K_A, outcome="yes"), ComboLeg(market_id=K_B, outcome="no")] + group = await engine.create_rfq_group(legs=legs, quantity=D("10")) + + members = by_venue(group) + assert set(members) == {"kalshi", "polymarket"} + kalshi, poly = members["kalshi"], members["polymarket"] + assert kalshi.collection_id == "KXMVE-1" and adapters["kalshi"].combo_collections == ["KXMVE-1"] + assert [l.event_id for l in kalshi.legs] == ["KXA-26", "KXB-26"] + # The match swaps KXB's sides on Polymarket: KXB NO is 222 YES. + assert [(l.market_id, l.outcome) for l in poly.legs] == [(P_A, "yes"), (P_B, "yes")] + assert poly.legs[1].info["position_id"] == f"{P_B}-Y" + assert poly.combo.market_id == poly.instrument_id and poly.instrument_id.startswith("polymarket:combo-") + skipped = {s.venue: s for s in group.skipped} + assert skipped["polymarket_us"].code == "missing_leg" and skipped["polymarket_us"].leg == K_B + assert skipped["limitless"].code == "ineligible_leg" and skipped["limitless"].leg == K_B + assert skipped["limitless"].reason == f"not combo-eligible on Limitless: “{K_B}”" + assert "combo-eligible list" in skipped["limitless"].detail + assert [(m.market_id, m.code, m.mapped_id) for m in skipped["limitless"].missing_legs] == [ + (K_B, "ineligible_leg", L_B)] + assert all(auth == "Bearer spk_test" for *_, auth in seen) + assert len(group.quotes) == 2 + await engine.stop() + + async def test_a_kalshi_leg_in_no_collection_is_outside_its_collections(self, tmp_path): + adapters = venues() + adapters["kalshi"].collections = [{"collection_ticker": "KXMVE-1", "associated_event_tickers": ["KXA-26"]}] + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group( + legs=[ComboLeg(market_id=K_A, outcome="yes"), ComboLeg(market_id=K_B, outcome="no")], quantity=D("10"), + venues=["kalshi", "polymarket"]) + skipped = {s.venue: s for s in group.skipped} + assert skipped["kalshi"].code == "outside_collections" and skipped["kalshi"].leg == K_B + assert skipped["kalshi"].reason == f"Kalshi combos are sports and cross-category only: “{K_B}” is in none" + assert "KXB-26" in skipped["kalshi"].detail + assert skipped["polymarket_us"].code == "excluded" + assert [m.venue for m in group.members] == ["polymarket"] + await engine.stop() + + async def test_a_collection_with_size_max_zero_has_no_maximum(self, tmp_path): + adapters = venues() + adapters["kalshi"].collections = [{"collection_ticker": "KXMVE-0", "associated_event_tickers": ["KXA-26", "KXB-26"], + "size_min": 2, "size_max": 0}] + engine = await started(tmp_path, adapters) + preview = await engine.preview_rfq_group(legs=[ComboLeg(market_id=K_A, outcome="yes"), + ComboLeg(market_id=K_B, outcome="no")], venues=["kalshi"]) + assert [(v.venue, v.collection_id) for v in preview.offered] == [("kalshi", "KXMVE-0")] + await engine.stop() + + async def test_no_venue_left_is_refused_with_the_reasons(self, tmp_path): + engine = await started(tmp_path, venues()) + with pytest.raises(BadRequest) as caught: + await engine.create_rfq_group(legs=[ComboLeg(market_id=K_A, outcome="yes"), + ComboLeg(market_id=K_B, outcome="no")], + quantity=D("10"), venues=["polymarket_us"]) + assert [s["code"] for s in caught.value.skipped if s["venue"] == "polymarket_us"] == ["missing_leg"] + assert await engine.journal.intents() == [] + await engine.stop() + + +class TestPreview: + async def test_plans_like_a_create_and_creates_nothing(self, tmp_path): + seen: list = [] + adapters = venues(limitless=True) + engine = await started(tmp_path, adapters, seen=seen) + legs = [ComboLeg(market_id=K_A, outcome="yes"), ComboLeg(market_id=K_B, outcome="no")] + preview = await engine.preview_rfq_group(legs=legs) + + offered = {v.venue: v for v in preview.offered} + assert set(offered) == {"kalshi", "polymarket"} + assert offered["kalshi"].collection_id == "KXMVE-1" and offered["kalshi"].via == "combo" + assert [(l.market_id, l.outcome) for l in offered["polymarket"].legs] == [(P_A, "yes"), (P_B, "yes")] + assert {s.venue: s.code for s in preview.skipped} == {"polymarket_us": "missing_leg", + "limitless": "ineligible_leg"} + # Nothing created, journaled or sent. + assert all(not a.rfqs and not a.combos for a in adapters.values()) + assert await engine.journal.intents() == [] and engine.rfq_groups.list() == [] + # The group that follows reuses the matching answers. + asked = len(seen) + group = await engine.create_rfq_group(legs=legs, amount=D("10")) + assert len(seen) == asked and {m.venue for m in group.members} == set(offered) + await engine.stop() + + async def test_a_plain_market_and_one_sided_sizes(self, tmp_path): + adapters = venues() + adapters["polymarket"].rfq_one_sided = True + engine = await started(tmp_path, adapters) + unsized = await engine.preview_rfq_group(K_A, cross_venue=True) + assert {v.venue: v.instrument_id for v in unsized.offered} == {"kalshi": K_A, "polymarket": P_A, + "polymarket_us": U_A} + by_quantity = await engine.preview_rfq_group(K_A, cross_venue=True, quantity=D("5")) + assert {s.venue: s.code for s in by_quantity.skipped} == {"polymarket": "one_sided_size"} + with pytest.raises(BadRequest): + await engine.preview_rfq_group() + await engine.stop() + + +class TestRanking: + async def test_by_effective_price_after_each_venues_fee(self, tmp_path): + engine = await started(tmp_path, venues()) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + assert {m.venue: m.instrument_id for m in group.members} == {"kalshi": K_A, "polymarket": P_A, + "polymarket_us": U_A} + order = [(q.venue, q.raw_price, q.fee, q.effective_price) for q in group.quotes] + # Raw: Kalshi 0.43 < US 0.44 < Polymarket 0.455. After fees US (2c) falls behind Polymarket (free): + # Kalshi 0.43 + ceil(0.07 * 10 * 0.43 * 0.57) / 10 = 0.448. + assert order == [("kalshi", D("0.43"), D("0.18"), D("0.448")), + ("polymarket", D("0.455"), D("0"), D("0.455")), + ("polymarket_us", D("0.44"), D("0.20"), D("0.46"))] + assert [q.rank for q in group.quotes] == [1, 2, 3] + await engine.stop() + + async def test_a_no_buy_ranks_on_the_bid(self, tmp_path): + engine = await started(tmp_path, venues()) + group = await engine.create_rfq_group(K_A, quantity=D("10"), outcome="no", action="buy", cross_venue=True) + # NO costs 1 - bid: Polymarket 0.58, Kalshi 0.60 + fee, US 0.61 + 2c. + assert [(q.venue, q.outcome_price) for q in group.quotes] == [ + ("polymarket", D("0.58")), ("kalshi", D("0.60")), ("polymarket_us", D("0.61"))] + await engine.stop() + + async def test_by_amount_ranks_on_contracts_after_fees(self, tmp_path): + engine = await started(tmp_path, venues()) + group = await engine.create_rfq_group(K_A, amount=D("100"), cross_venue=True) + rows = {q.venue: q for q in group.quotes} + # US: 227 whole contracts at 0.44 would cost 99.88 + 4.54 fee; after the fee, 216. + assert rows["polymarket_us"].quantity == D("216") and rows["polymarket_us"].fee == D("4.32") + assert rows["polymarket"].quantity == D("219.78") + for row in rows.values(): + assert row.quantity * row.outcome_price + (row.fee or 0) <= D("100") + assert [q.venue for q in group.quotes] == ["kalshi", "polymarket", "polymarket_us"] + assert group.quotes[0].quantity > rows["polymarket"].quantity + await engine.stop() + + async def test_a_one_sided_venue_is_asked_only_the_size_it_takes(self, tmp_path): + adapters = venues() + adapters["polymarket"].rfq_one_sided = True # Polymarket global: a YES buy by amount + engine = await started(tmp_path, adapters) + by_quantity = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + assert {s.venue: s.code for s in by_quantity.skipped} == {"polymarket": "one_sided_size"} + by_amount = await engine.create_rfq_group(K_A, amount=D("50"), cross_venue=True) + assert "polymarket" in by_venue(by_amount) and not by_amount.skipped + await engine.stop() + + async def test_quotes_arrive_as_group_events(self, tmp_path): + engine = await started(tmp_path, venues()) + sub = engine.bus.subscribe("rfq_group") + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + kinds = [e.kind for e in drain(sub)] + assert kinds[0] == "rfq_group.created" + assert kinds.count("rfq_group.quote") == 3 and kinds[-1] == "rfq_group.updated" + assert kinds.index("rfq_group.quote") < kinds.index("rfq_group.updated") + assert len(group.quotes) == 3 + await engine.stop() + + +class TestAccept: + async def test_cancels_the_others_first_and_one_without_a_cancel_is_left_to_expire(self, tmp_path): + adapters = venues() + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + best = group.best() + assert best.venue == "kalshi" + sub = engine.bus.subscribe("rfq_group", "rfq.closed") + done = await engine.accept_group_quote(group.id, best.quote_id, venue=best.venue, wait=True) + members = by_venue(done.group) + assert members["polymarket_us"].status == "canceled" + assert members["polymarket"].status == "lapsing" and "lapses" in members["polymarket"].detail + assert members["kalshi"].status == "executed" and done.group.status == "executed" + kinds = [e.kind for e in drain(sub)] + assert kinds.index("rfq_group.accepting") < kinds.index("rfq.closed") < kinds.index("rfq_group.accepted") + assert "rfq_group.executed" in kinds + assert adapters["polymarket_us"].calls.count("cancel_rfq") == 1 + assert adapters["kalshi"].calls.count("accept_quote") == 1 + claim = await engine.journal.intent(done.claim_key) + assert claim.state == IntentState.SENT and claim.operation == "rfq_group_accept" + order = await engine.journal.order_by_client_id(done.client_order_id) + assert order.status == OrderStatus.CLOSED and order.tags["rfq_group"] == group.id + await engine.stop() + + async def test_a_second_accept_is_refused_in_memory_and_in_the_journal(self, tmp_path): + adapters = venues(kalshi_makers=[PaperMaker(name="k1", bid=D("0.40"), ask=D("0.43")), + PaperMaker(name="k2", bid=D("0.39"), ask=D("0.44"))]) + for venue in adapters.values(): + venue.auto_advance = False + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + first, second = [q for q in group.quotes if q.venue == "kalshi"] + await engine.accept_group_quote(group.id, first.quote_id, venue=first.venue) + assert group.status == "accepted" + with pytest.raises(RiskRejected) as caught: + await engine.accept_group_quote(group.id, second.quote_id, venue=second.venue) + assert caught.value.rule == "rfq_group_accept_in_flight" + # Even with the engine's memory wrong, the journal holds the claim. + group.status = by_venue(group)["kalshi"].status = "open" + with pytest.raises(RiskRejected): + await engine.accept_group_quote(group.id, second.quote_id, venue=second.venue) + # And around the group: a member quote is accepted only through it. + with pytest.raises(BadRequest, match="through the group"): + await engine.accept_quote(AcceptQuote(quote_id=second.quote_id, rfq_id=second.rfq_id, outcome="yes", + action="buy"), venue="kalshi") + assert adapters["kalshi"].calls.count("accept_quote") == 1 + group.status = by_venue(group)["kalshi"].status = "accepted" + adapters["kalshi"].advance() + adapters["kalshi"].advance() + await engine.rfq.tick() + assert group.status == "executed" + with pytest.raises(RiskRejected) as caught: + await engine.accept_group_quote(group.id, second.quote_id, venue=second.venue) + assert caught.value.rule == "rfq_group_accepted" + await engine.stop() + + async def test_an_accept_that_does_not_trade_is_retried_on_another_quote(self, tmp_path): + adapters = venues(kalshi_makers=[PaperMaker(name="k1", bid=D("0.40"), ask=D("0.43"), confirms=False), + PaperMaker(name="k2", bid=D("0.39"), ask=D("0.44"))]) + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + lapsing, other = [q for q in group.quotes if q.venue == "kalshi"] + done = await engine.accept_group_quote(group.id, lapsing.quote_id, venue=lapsing.venue, wait=True) + assert done.quote.status == QuoteStatus.EXPIRED + assert group.status == "open" and group.failures[-1]["stage"] == "accept" + assert by_venue(group)["polymarket_us"].status == "canceled", "what was cancelled stays cancelled" + assert [q.quote_id for q in group.quotes if q.rank] == [other.quote_id] + first_claim = done.claim_key + done = await engine.accept_group_quote(group.id, other.quote_id, venue=other.venue, wait=True) + assert done.group.status == "executed" and done.claim_key != first_claim + assert (await engine.journal.intent(first_claim)).state == IntentState.FAILED + await engine.stop() + + async def test_a_risk_refusal_leaves_every_venue_standing(self, tmp_path): + adapters = venues() + engine = await started(tmp_path, adapters, max_order_contracts=D("5")) + group = await engine.create_rfq_group(K_A, quantity=D("5"), cross_venue=True) + with pytest.raises(RiskRejected) as caught: + await engine.accept_group_quote(group.id, group.best().quote_id, venue=group.best().venue, worst_price=D("0.40")) + assert caught.value.rule == "worst_price" + assert all(m.status == "open" for m in group.members) and group.status == "open" + assert not [i for i in await engine.journal.intents() if i.operation == "rfq_group_accept"] + await engine.stop() + + +class TestRecovery: + async def test_a_crash_between_the_cancels_and_the_accept_releases_the_claim(self, tmp_path): + adapters = venues() + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + best = group.best() + + async def dies(*args, **kwargs): + raise Crash() + + engine.rfq.accept_quote = dies + with pytest.raises(Crash): + await engine.accept_group_quote(group.id, best.quote_id, venue=best.venue) + await crashed(engine, adapters) + + engine = await started(tmp_path, adapters) + again = await engine.rfq_group(group.id) + members = by_venue(again) + assert again.status == "open" and members["kalshi"].status == "open" + assert members["polymarket_us"].status == "canceled" and members["polymarket"].status == "lapsing" + assert "accept_quote" not in adapters["kalshi"].calls + claim = await engine.journal.intent(f"rfq-group-accept:{group.id}:1") + assert claim.state == IntentState.FAILED and "nothing traded" in claim.detail + done = await engine.accept_group_quote(group.id, best.quote_id, venue=best.venue, wait=True) + assert done.group.status == "executed" and done.claim_key.endswith(":2") + assert adapters["kalshi"].calls.count("accept_quote") == 1 + await engine.stop() + + async def test_restart_restores_the_group_and_reseeds_its_members(self, tmp_path): + adapters = venues() + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + kalshi_rfq = by_venue(group)["kalshi"].rfq_id + await crashed(engine, adapters) + + engine = await started(tmp_path, adapters) + again = await engine.rfq_group(group.id) + assert again.status == "open" and [q.rank for q in again.quotes] == [1, 2, 3] + assert kalshi_rfq in adapters["kalshi"].reseeded[-1] + assert engine.rfq_groups.group_of("kalshi", kalshi_rfq) == group.id + done = await engine.accept_group_quote(group.id, again.best().quote_id, venue=again.best().venue, wait=True) + assert done.group.status == "executed" + await engine.stop() + + async def test_a_member_create_in_doubt_is_adopted_into_its_group(self, tmp_path): + from synpath.errors import NetworkError + + adapters = venues() + real = adapters["polymarket_us"].create_rfq + + async def lost(*args, **kwargs): + await real(*args, **kwargs) # the venue made it; the answer is lost + raise NetworkError("polymarket_us: timed out") + + adapters["polymarket_us"].create_rfq = lost + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + assert by_venue(group)["polymarket_us"].status == "creating" and group.status == "open" + await engine.sweep() + await engine.rfq.tick() + assert by_venue(group)["polymarket_us"].status == "open" + await engine.stop() + + +class TestHalt: + async def test_the_halt_cancels_open_groups(self, tmp_path): + adapters = venues() + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + result = await engine.halt("operator") + assert result["rfq"]["groups"][group.id]["status"] == "canceled" + assert group.status == "canceled" + assert {m.venue: m.status for m in group.members} == {"kalshi": "canceled", "polymarket": "lapsing", + "polymarket_us": "canceled"} + with pytest.raises(BadRequest, match="canceled"): + await engine.accept_group_quote(group.id, group.quotes[0].quote_id, venue=group.quotes[0].venue) + await engine.stop() + + async def test_an_accept_is_refused_while_halted_before_any_cancel(self, tmp_path): + adapters = venues() + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + await engine.halt("operator", policy="hold") + with pytest.raises(RiskRejected): + await engine.accept_group_quote(group.id, group.best().quote_id, venue=group.best().venue) + assert all(m.status == "open" for m in group.members) + await engine.stop() + + async def test_a_group_every_venue_refuses_is_the_refusal(self, tmp_path): + engine = await started(tmp_path, venues()) + await engine.halt("operator") + with pytest.raises(RiskRejected): + await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + (row,) = [g for g in engine.rfq_groups.list()] + assert row.status == "failed" and {f["stage"] for f in row.failures} == {"create"} + await engine.stop() + + +class TestAcceptByVenue: + async def test_two_venues_with_the_same_quote_id_are_told_apart_by_venue(self, tmp_path): + adapters = venues() + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + kalshi, us = quote_on(group, "kalshi"), quote_on(group, "polymarket_us") + assert kalshi.quote_id == us.quote_id, "each paper venue numbers its own quotes: the ids collide" + with pytest.raises(BadRequest, match="name the quote's venue") as caught: + await engine.accept_group_quote(group.id, us.quote_id) + assert caught.value.reason == "venue_required" and "on 3 venues" in str(caught.value) + assert all(m.status == "open" for m in group.members), "a refused accept leaves every venue standing" + # Not the best quote (Kalshi's is), but the one named: Polymarket US's. + done = await engine.accept_group_quote(group.id, us.quote_id, venue="polymarket_us", wait=True) + assert done.venue == "polymarket_us" and done.group.accepted_venue == "polymarket_us" + assert adapters["polymarket_us"].calls.count("accept_quote") == 1 + assert adapters["kalshi"].calls.count("accept_quote") == 0 + assert by_venue(done.group)["kalshi"].status == "canceled" + claim = await engine.journal.intent(done.claim_key) + assert claim.target_order_id == us.quote_id and claim.venue == "polymarket_us" + await engine.stop() + + async def test_a_venue_outside_the_group_is_refused(self, tmp_path): + engine = await started(tmp_path, venues()) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + with pytest.raises(BadRequest, match="no member on limitless") as caught: + await engine.accept_group_quote(group.id, group.best().quote_id, venue="limitless") + assert caught.value.reason == "venue_not_in_group" + await engine.stop() + + async def test_a_one_venue_group_needs_no_venue(self, tmp_path): + engine = await started(tmp_path, venues()) + group = await engine.create_rfq_group(K_A, quantity=D("10")) + assert group.venues() == {"kalshi"} + done = await engine.accept_group_quote(group.id, group.best().quote_id, wait=True) + assert done.venue == "kalshi" and done.group.status == "executed" + await engine.stop() + + +class TestExpiry: + async def test_a_quote_carries_its_venues_expiry_and_none_is_invented(self, tmp_path): + adapters = venues(poly_makers=[PaperMaker(name="p1", bid=D("0.42"), ask=D("0.455"), ttl_s=5)]) + adapters["polymarket_us"].rfq_ttl_s = 60 + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + poly, us, kalshi = (quote_on(group, v) for v in ("polymarket", "polymarket_us", "kalshi")) + assert poly.expires_from == "quote" and poly.expires_at == poly_quote(adapters, poly).expires_at + assert us.expires_from == "rfq" and us.expires_at == adapters["polymarket_us"].rfqs[us.rfq_id].expires_at + assert kalshi.expires_at is None and kalshi.expires_from is None, "Kalshi gives none: live, no countdown" + assert group.expires_at == poly.expires_at, "the soonest live quote's expiry" + await engine.stop() + + async def test_an_expired_quote_is_unranked_and_the_group_closes_when_every_rfq_has(self, tmp_path): + import time as clock + now = [clock.time()] + adapters = venues(poly_makers=[PaperMaker(name="p1", bid=D("0.42"), ask=D("0.455"), ttl_s=5)]) + for venue in adapters.values(): + venue.clock = lambda: now[0] + venue.rfq_ttl_s = 30 + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10"), cross_venue=True) + sub = engine.bus.subscribe("rfq_group", "quote", "rfq.closed") + now[0] += 6 + await engine.rfq.tick() + poly = quote_on(group, "polymarket") + assert poly.status == QuoteStatus.EXPIRED and poly.rank is None + assert group.status == "open" and group.expires_at == quote_on(group, "kalshi").expires_at + now[0] += 30 + engine.rfq._rfq_read.clear() # the follower's next status read (every RFQ_STATUS_INTERVAL_S) + await engine.rfq.tick() + assert group.status == "expired", "every member RFQ closed at its venue: no timer of ours" + assert all(m.status == "closed" for m in group.members) + kinds = [e.kind for e in drain(sub)] + assert "quote.expired" in kinds and kinds.count("rfq.closed") == 3 and "rfq_group.updated" in kinds + await engine.stop() + + async def test_a_venue_that_closes_an_rfq_with_no_stated_expiry_is_seen(self, tmp_path): + from synpath.trading.types import RfqCloseReason, RfqStatus + adapters = venues() + engine = await started(tmp_path, adapters) + group = await engine.create_rfq_group(K_A, quantity=D("10")) + kalshi = adapters["kalshi"] + member = group.members[0] + assert kalshi.rfqs[member.rfq_id].expires_at is None + kalshi.rfqs[member.rfq_id] = kalshi.rfqs[member.rfq_id].model_copy( + update={"status": RfqStatus.CLOSED, "closed_reason": RfqCloseReason.EXPIRED}) + engine.rfq._rfq_read.clear() + await engine.rfq.tick() + assert group.status == "expired" and member.status == "closed" + await engine.stop() + + +def poly_quote(adapters, entry): + return adapters["polymarket"].quotes[entry.quote_id] diff --git a/tests/test_feeds.py b/tests/test_feeds.py index 7841a65..8f5c450 100644 --- a/tests/test_feeds.py +++ b/tests/test_feeds.py @@ -31,7 +31,9 @@ class FakeStream: def __init__(self, venue: str, name: str, *, market: bool = True, private: bool = False): self.venue, self.name = venue, name - self.has = {"watch_order_book": market, "watch_orders": private, "watch_my_trades": private} + self.has = {"watch_order_book": market, "watch_trades": market, "watch_orders": private, + "watch_my_trades": private} + self.trades_watched: list[list[str]] = [] self.books: dict[str, LocalBook] = {} self.watched: list[list[str]] = [] self.subscribed: list[str] = [] @@ -51,6 +53,9 @@ async def watch_order_book(self, market_ids): for m in market_ids: self.books.setdefault(m, LocalBook()) + async def watch_trades(self, market_ids): + self.trades_watched.append(list(market_ids)) + async def watch_orders(self, market_ids=None): self.subscribed.append("orders") @@ -125,6 +130,7 @@ async def test_a_new_parent_s_market_is_subscribed_on_its_venue(self, setup): await engine.submit(stop_request()) assert await feeds.sync() == 1 assert k_stream.watched == [[K]] and p_market.watched == [] + assert k_stream.trades_watched == [[K]], "its prints too: a last-trade stop hears every one" assert engine.books[K] is k_stream.books[K], "the engine reads the stream's own book object" assert await feeds.sync() == 0, "already watched" @@ -152,7 +158,7 @@ async def test_a_stop_fires_from_a_streamed_book_and_not_before_the_snapshot(sel kalshi.set_book(K, bids=[(D("0.38"), D("100"))]) # where the child will actually trade parent = await engine.submit(stop_request()) await feeds.sync() - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) await feeds.dispatch("kalshi", k_stream, BookEvent(venue="kalshi", market_id=K, kind="delta")) assert managed.state == "waiting", "no snapshot yet: the book is not ready and reads as no book" @@ -160,7 +166,7 @@ async def test_a_stop_fires_from_a_streamed_book_and_not_before_the_snapshot(sel await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, bids=[("0.45", "100")], asks=[("0.47", "100")])) assert managed.state == "waiting" await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, bids=[("0.39", "100")], asks=[("0.41", "100")])) - assert managed.state == "working" and len(managed.children) == 1 + assert managed.state in ("working", "done") and len(managed.children) == 1 async def test_a_book_invalidated_after_a_gap_does_not_fire_a_stop(self, setup): engine, feeds, kalshi, poly, k_stream, p_market, p_user = setup @@ -170,7 +176,68 @@ async def test_a_book_invalidated_after_a_gap_does_not_fire_a_stop(self, setup): k_stream.books[K].bids = {D("0.30"): D("100")} # stale levels left behind by a gap k_stream.books[K].invalidate() await feeds.dispatch("kalshi", k_stream, BookEvent(venue="kalshi", market_id=K, kind="delta")) - assert engine.orders.get(parent.id).state == "waiting" + assert engine.managed_order_controller.get(parent.id).state == "waiting" + + +class TestMarks: + """A held position is marked at the price it could be closed at on the + live book the engine holds for it: the bid for a long, the ask for a short.""" + + async def fill(self, engine, side, quantity="5", price="0.40", fill_id="F1"): + await engine.on_fill(Fill(id=fill_id, order_id="O1", venue="kalshi", account=ACCOUNTS["kalshi"], + market_id=K, side=side, price=D(price), quantity=D(quantity), timestamp=1)) + + async def test_a_long_yes_is_marked_at_the_bid_once_its_book_is_subscribed(self, setup): + engine, feeds, kalshi, poly, k_stream, p_market, p_user = setup + await self.fill(engine, Side.BUY) + assert await feeds.sync() == 1 and k_stream.watched == [[K]], "a held market is subscribed with no order on it" + [position] = engine.positions() + assert position.mark_price is None, "no snapshot yet: no mark rather than a guessed one" + + await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, bids=[("0.44", "10")], asks=[("0.48", "10")])) + [position] = engine.positions() + assert position.mark_price == D("0.44") and position.unrealized_pnl == D("0.20") + assert engine.pnl("account")["total"].unrealized == D("0.20") + + async def test_a_short_yes_which_is_a_long_no_is_marked_at_the_ask(self, setup): + engine, feeds, kalshi, poly, k_stream, p_market, p_user = setup + await self.fill(engine, Side.SELL, price="0.60") # Buy NO at 0.40 + await feeds.sync() + await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, bids=[("0.44", "10")], asks=[("0.48", "10")])) + [position] = engine.positions() + # Closing buys YES back at 0.48, which is selling NO at 0.52: 0.12 a contract on 5. + assert position.mark_price == D("0.48") and position.unrealized_pnl == D("0.60") + + async def test_the_last_trade_marks_when_nobody_is_on_the_closing_side_or_the_book_is_not_live(self, setup): + engine, feeds, kalshi, poly, k_stream, p_market, p_user = setup + await self.fill(engine, Side.BUY) + await feeds.sync() + [position] = engine.positions() + assert position.mark_price is None, "no book and no trade: no mark" + + await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, asks=[("0.48", "10")])) + await engine.on_trade({"market_id": K, "price": "0.47", "quantity": "1"}) + [position] = engine.positions() + assert position.mark_price == D("0.47"), "no bid: a long is marked at the last trade" + + await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, bids=[("0.44", "10")], asks=[("0.48", "10")])) + assert engine.positions()[0].mark_price == D("0.44") + k_stream.books[K].invalidate() + [position] = engine.positions() + assert position.mark_price == D("0.47") and position.unrealized_pnl == D("0.35") + + +class TestFillOutcome: + async def test_a_fill_carries_its_order_s_outcome_and_action(self, setup): + engine, feeds, kalshi, poly, k_stream, p_market, p_user = setup + kalshi.set_book(K, bids=[(D("0.40"), D("100"))], asks=[(D("0.45"), D("100"))]) + order = await engine.submit(OrderRequest(market_id=K, outcome="yes", action="sell", quantity=D("5"), + price=D("0.44"), account=ACCOUNTS["kalshi"])) + assert (order.outcome, order.action) == ("yes", "sell") + for fill in kalshi.on_trade(K, price=D("0.44"), quantity=D("10"), taker_side=Side.BUY): + await engine.on_fill(fill) + [fill] = await engine.journal.fills() + assert (fill.side, fill.outcome, fill.action) == (Side.SELL, "yes", "sell") class TestAccountStreams: @@ -179,7 +246,7 @@ async def test_an_order_update_on_the_private_stream_completes_the_parent(self, parent = await engine.submit(stop_request()) await feeds.sync() await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, bids=[("0.39", "100")], asks=[("0.41", "100")])) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) child = managed.children[0] venue_order = kalshi.orders[child.order_id] assert managed.filled == venue_order.filled, "the placement's answer is the first record" @@ -194,7 +261,7 @@ async def test_a_fill_event_books_in_the_ledger_and_leaves_the_parent_alone(self await feeds.sync() kalshi.set_book(K, bids=[]) # nothing to cross: the child rests await feeds.dispatch("kalshi", k_stream, k_stream.snapshot(K, bids=[("0.39", "100")], asks=[("0.41", "100")])) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) child = managed.children[0] before = managed.filled fill = Fill(id="f-1", order_id=child.order_id, venue="kalshi", account=ACCOUNTS["kalshi"], market_id=K, @@ -243,7 +310,7 @@ async def test_the_engine_names_every_loop_a_host_must_run(self, setup): engine, *_ = setup loops = engine.background() try: - assert set(loops) == {"lease", "sweep", "poll", "managed"} + assert set(loops) == {"lease", "sweep", "poll", "managed", "dispatch", "rfq"} finally: for coro in loops.values(): coro.close() diff --git a/tests/test_hyperliquid_trading.py b/tests/test_hyperliquid_trading.py index 7493e29..39fc0f7 100644 --- a/tests/test_hyperliquid_trading.py +++ b/tests/test_hyperliquid_trading.py @@ -18,8 +18,8 @@ from conftest import load +# The private key the venue's Python SDK signs its test vectors with. SDK_KEY = "0x0123456789012345678901234567890123456789012345678901234567890123" -"""The private key the venue's Python SDK signs its test vectors with.""" def sdk_order(**extra): @@ -100,8 +100,8 @@ def test_sell_buys_the_no_coin_at_the_complement(self): assert outcome == "no" and price == Decimal("0.3") assert wire["a"] == asset_of("7544", "no") == 100_075_441 and wire["b"] is True and wire["p"] == "0.3" - def test_reduce_only_sells_the_token_held(self): - wire, _, outcome, _ = build_order_wire(request(side=Side.SELL, reduce_only=True)) + def test_selling_yes_sells_the_token_held(self): + wire, _, outcome, _ = build_order_wire(request(side=Side.SELL, outcome="yes", action="sell")) assert outcome == "yes" and wire["b"] is False def test_order_kinds(self): diff --git a/tests/test_hyperliquid_ws.py b/tests/test_hyperliquid_ws.py index fc41f5d..15a6a14 100644 --- a/tests/test_hyperliquid_ws.py +++ b/tests/test_hyperliquid_ws.py @@ -115,8 +115,8 @@ def test_a_question_id_is_refused(self): def test_every_book_message_is_a_snapshot(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps(WS["l2Book"])) - stream._dispatch(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) events = [e for e in drain(stream) if isinstance(e, BookEvent)] assert [e.kind for e in events] == ["snapshot", "snapshot"] book = stream.book(MARKET) @@ -128,27 +128,27 @@ def test_the_book_object_stays_the_same(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) before = stream.book(MARKET) - stream._dispatch(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) assert stream.book(MARKET) is before def test_a_reconnect_marks_books_not_ready_until_the_next_message(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) stream.on_disconnect() assert not stream.book(MARKET).ready drain(stream) - stream._dispatch(json.dumps(WS["l2Book"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["l2Book"])) states = [e.state for e in drain(stream) if isinstance(e, StreamStatusEvent)] assert stream.book(MARKET).ready and states == ["resynced"] def test_prints_from_before_the_subscription_are_dropped(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_trades([MARKET]))) - stream._dispatch(json.dumps(WS["trades"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["trades"])) assert not [e for e in drain(stream) if isinstance(e, TradeEvent)] stream.trades_since["#75440"] = 0 - stream._dispatch(json.dumps(WS["trades"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["trades"])) trades = [e for e in drain(stream) if isinstance(e, TradeEvent)] assert len(trades) == len(WS["trades"]["data"]) row = WS["trades"]["data"][0] @@ -158,8 +158,8 @@ def test_prints_from_before_the_subscription_are_dropped(self): def test_quotes_and_volume(self): stream = HyperliquidMarketStream() asyncio.run(watching(stream, stream.watch_ticker([MARKET]))) - stream._dispatch(json.dumps(WS["bbo"])) - stream._dispatch(json.dumps(WS["activeSpotAssetCtx"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["bbo"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["activeSpotAssetCtx"])) bbo, ctx = [e for e in drain(stream) if isinstance(e, QuoteEvent)] assert bbo.bid == Decimal(WS["bbo"]["data"]["bbo"][0]["px"]) assert bbo.ask == Decimal(WS["bbo"]["data"]["bbo"][1]["px"]) @@ -167,9 +167,9 @@ def test_quotes_and_volume(self): def test_acknowledgements_and_pongs(self): stream = HyperliquidMarketStream() - stream._dispatch(json.dumps(WS["subscriptionResponse"])) - stream._dispatch(json.dumps(WS["pong"])) - stream._dispatch(json.dumps({"channel": "error", "data": "Invalid subscription"})) + stream._process_raw_frame_and_emit_events(json.dumps(WS["subscriptionResponse"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["pong"])) + stream._process_raw_frame_and_emit_events(json.dumps({"channel": "error", "data": "Invalid subscription"})) states = [e.state for e in drain(stream) if isinstance(e, StreamStatusEvent)] assert states == ["subscribed", "error"] @@ -198,14 +198,14 @@ def test_subscriptions_name_the_address(self): def test_the_fill_snapshot_is_reported_not_replayed(self): stream = self.user() - stream._dispatch(json.dumps(WS["userFills_snapshot"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["userFills_snapshot"])) events = drain(stream) assert not [e for e in events if isinstance(e, FillEvent)] assert [e.state for e in events if isinstance(e, StreamStatusEvent)] == ["subscribed"] def test_new_fills_and_transfers(self): stream = self.user() - stream._dispatch(json.dumps({"channel": "userFills", "data": {"user": stream.address, "fills": FILLS}})) + stream._process_raw_frame_and_emit_events(json.dumps({"channel": "userFills", "data": {"user": stream.address, "fills": FILLS}})) events = drain(stream) fills = [e for e in events if isinstance(e, FillEvent)] transfers = [e for e in events if isinstance(e, VenueEvent)] @@ -214,14 +214,14 @@ def test_new_fills_and_transfers(self): def test_order_updates(self): stream = self.user() - stream._dispatch(json.dumps({"channel": "orderUpdates", "data": ORDERS})) + stream._process_raw_frame_and_emit_events(json.dumps({"channel": "orderUpdates", "data": ORDERS})) orders = [e for e in drain(stream) if isinstance(e, OrderEvent)] assert [e.native for e in orders] == [row["status"] for row in ORDERS] def test_a_market_filter_narrows(self): stream = self.user() asyncio.run(watching(stream, stream.watch_orders(["hyperliquid:3600"]))) - stream._dispatch(json.dumps({"channel": "orderUpdates", "data": ORDERS})) + stream._process_raw_frame_and_emit_events(json.dumps({"channel": "orderUpdates", "data": ORDERS})) assert not [e for e in drain(stream) if isinstance(e, OrderEvent)] diff --git a/tests/test_journal_v3.py b/tests/test_journal_v3.py index 50d81de..6983843 100644 --- a/tests/test_journal_v3.py +++ b/tests/test_journal_v3.py @@ -64,7 +64,7 @@ class TestUpgradeFromTwo: async def test_a_fresh_file_is_the_current_version_with_the_new_shape(self, tmp_path: Path): path = str(tmp_path / "fresh.db") async with Journal(path) as journal: - assert await journal.schema_version() == SCHEMA_VERSION == 4 + assert await journal.schema_version() == SCHEMA_VERSION == 11 assert "parent_id" in columns(path, "orders") assert columns(path, "managed_orders")["account_key"] is False assert "bucket_id" in columns(path, "bucket_members") @@ -76,7 +76,7 @@ async def test_a_version_two_file_is_upgraded_and_keeps_its_rows(self, tmp_path: assert columns(path, "managed_orders")["account_key"] is True async with Journal(path) as journal: - assert await journal.schema_version() == 4 + assert await journal.schema_version() == SCHEMA_VERSION parents = await journal.managed(states=("working",)) assert [p["id"] for p in parents] == ["mo-1"] assert "parent_id" in columns(path, "orders") @@ -94,7 +94,7 @@ async def test_opening_twice_is_idempotent(self, tmp_path: Path): async with Journal(path): pass async with Journal(path) as journal: - assert await journal.schema_version() == 4 + assert await journal.schema_version() == SCHEMA_VERSION assert len(await journal.managed(states=("working",))) == 1 @@ -170,9 +170,16 @@ async def test_round_trip_keeps_member_order_and_flip(self, tmp_path: Path): await journal.save_bucket(bucket) found = await journal.bucket("b-1") assert found["name"] == "Falcons -7.5 2H" and found["status"] == "active" - assert found["members"] == bucket["members"] + assert found["members"] == [{**m, "enabled": True} for m in bucket["members"]] assert await journal.bucket("nope") is None + async def test_a_member_switched_off_stays_off(self, tmp_path: Path): + async with Journal(str(tmp_path / "b.db")) as journal: + await journal.save_bucket({"id": "b-1", "book": "alpha", "name": "x", "members": [ + {"market_id": "kalshi:KX-A"}, {"market_id": "polymarket:1", "flip": True, "enabled": False}]}) + found = await journal.bucket("b-1") + assert [m["enabled"] for m in found["members"]] == [True, False] + async def test_saving_again_replaces_the_members(self, tmp_path: Path): async with Journal(str(tmp_path / "b.db")) as journal: await journal.save_bucket({"id": "b-1", "book": "alpha", "name": "x", @@ -180,7 +187,7 @@ async def test_saving_again_replaces_the_members(self, tmp_path: Path): await journal.save_bucket({"id": "b-1", "book": "alpha", "name": "x", "members": [{"market_id": "polymarket:2", "flip": True}]}) found = await journal.bucket("b-1") - assert found["members"] == [{"market_id": "polymarket:2", "flip": True}] + assert found["members"] == [{"market_id": "polymarket:2", "flip": True, "enabled": True}] async def test_listing_filters_by_book_and_archive_is_soft(self, tmp_path: Path): async with Journal(str(tmp_path / "b.db")) as journal: diff --git a/tests/test_kalshi.py b/tests/test_kalshi.py index f2e6465..e02a9ba 100644 --- a/tests/test_kalshi.py +++ b/tests/test_kalshi.py @@ -1247,3 +1247,17 @@ def test_open_search_skips_the_archive(self): search={"/v1/search/series": hits}) assert kalshi.search_markets("old", status="open") == [] assert all(path != "/historical/markets" for path, _ in calls) + + +class TestEventsByIds: + """`fetch_events_by_ids`: one batched `/events?tickers=` read, markets nested, in the order asked.""" + + def test_in_the_order_asked_with_markets_and_unknown_ids_left_out(self): + listings = TestArchivedListings() + rows = {"events": [{"event_ticker": "B", "title": "B", "markets": [listings.market("B-1", "B", "active")]}, + {"event_ticker": "A", "title": "A", "markets": [listings.market("A-1", "A", "active")]}]} + kalshi, calls = listings.kalshi({"/events": rows}) + found = kalshi.fetch_events_by_ids(["kalshi:A", "B", "kalshi:GONE"]) + assert [e.venue_event_id for e in found] == ["A", "B"] + assert [m.venue_market_id for m in found[0].markets] == ["A-1"] + assert calls == [("/events", {"tickers": "A,B,GONE", "with_nested_markets": "true", "limit": 3})] diff --git a/tests/test_kalshi_demo.py b/tests/test_kalshi_demo.py index db926d6..6f1c117 100644 --- a/tests/test_kalshi_demo.py +++ b/tests/test_kalshi_demo.py @@ -256,16 +256,27 @@ def test_trip_and_reset(self, kalshi, loop, market): class TestRFQ: + """The typed RFQ interface (docs/rfq-combo.md P6). Creates and deletes + one RFQ on a plain market; nothing is accepted or quoted, so nothing + trades. The accept side and the quoter flow are P10's, with notice.""" + def test_create_read_delete(self, kalshi, loop, market): - rfq_id = run(loop, kalshi.create_rfq(market, 10)) - assert rfq_id + from synpath.trading import RfqStatus + + rfq = run(loop, kalshi.create_rfq(market, quantity=D("10"))) + assert rfq.id and rfq.status == RfqStatus.OPEN try: - rfq = run(loop, kalshi.fetch_rfq(rfq_id)) - assert rfq["market_ticker"] == market and rfq["status"] == "open" and rfq["contracts_fp"] == "10.00" - assert run(loop, kalshi.fetch_quotes(rfq_id)) == [] # nobody quotes on the demo + read = run(loop, kalshi.fetch_rfq(rfq.id)) + assert read.market_id == f"kalshi:{market}" and read.quantity == D("10") and read.mine + assert rfq.id in {r.id for r in run(loop, kalshi.fetch_rfqs(status="open"))} + run(loop, kalshi.fetch_quotes(rfq.id)) # a demo maker may or may not answer finally: - run(loop, kalshi.delete_rfq(rfq_id)) - assert run(loop, kalshi.fetch_rfq(rfq_id))["status"] != "open" + run(loop, kalshi.cancel_rfq(rfq.id)) + assert run(loop, kalshi.fetch_rfq(rfq.id)).status == RfqStatus.CLOSED + + def test_collections_are_listed(self, kalshi, loop): + page = run(loop, kalshi.fetch_combo_collections(limit=5)) + assert all("collection_ticker" in row for row in page) class TestBudget: diff --git a/tests/test_kalshi_rfq.py b/tests/test_kalshi_rfq.py new file mode 100644 index 0000000..180ab07 --- /dev/null +++ b/tests/test_kalshi_rfq.py @@ -0,0 +1,705 @@ +"""Kalshi RFQ and combos on the typed shared interface, offline (P6 of docs/rfq-combo.md). + +Nothing here was captured live. The samples (`tests/samples/kalshi_rfq_*.json`) +follow https://docs.kalshi.com/openapi.yaml (v3.34.0) field for field, and the +shapes the P0 demo run saw: a quote with `yes_bid_dollars`, `no_bid_dollars`, +`yes_contracts_fp`, `no_contracts_fp`, statuses open, accepted, confirmed, +executed and cancelled, `rfq_creator_order_id` and `creator_order_id`; RFQs +with `mve_collection_ticker`, `mve_selected_legs` and `target_cost_dollars`. +The accept side mapping is the one P0 settled: buying YES sends +`accepted_side=no`. +""" +from __future__ import annotations + +import json +from decimal import Decimal +from pathlib import Path + +import httpx +import pytest +import respx +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from synpath.errors import BadRequest, MarketNotFound, NotSupported +from synpath.trading import ( + AcceptQuote, Combo, ComboLeg, MakerQuote, QuoteStatus, Rfq, RfqQuote, RfqStatus, Side, +) +from synpath.trading import kalshi as k +from synpath.trading.base import TradingExchange +from synpath.trading.credentials import KalshiCredentials +from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderRejected, RfqNotFound +from synpath.trading.limiter import BudgetLimiter +from synpath.trading.types import RfqCloseReason + +D = Decimal +pytestmark = pytest.mark.anyio +BASE = k.BASE_URLS["demo"] +SAMPLES = Path(__file__).parent / "samples" +COMBO = "KXMVECROSSCATEGORY-S2026GBYNMLIOB4P5GM6R-6TO3QWLMW" +COLL = "KXMVECROSSCATEGORY-R" +LEG_A = "KXNFLGAME-26OCT25KCDEN-KC" +LEG_B = "KXNBAGAME-26OCT25LALBOS-BOS" +EVENT_A, EVENT_B = "KXNFLGAME-26OCT25KCDEN", "KXNBAGAME-26OCT25LALBOS" +RFQ = "5b0e3c1e-8c0f-4c7e-9d1a-2f6a0c3b9e11" +QUOTE = "9d41f0aa-6b2e-4f51-8c33-0e7b5a1d2c44" +INCOMING = "e8d7c6b5-a4f3-4e2d-9c1b-0a9f8e7d6c5b" +TAKER_ORDER = "01a1b2c3-d4e5-7f60-8a9b-0c1d2e3f4a5b" +QUOTE_PATH = f"/communications/rfqs/{RFQ}/quotes/{QUOTE}" + + +def sample(name: str): + return json.loads((SAMPLES / f"kalshi_rfq_{name}.json").read_text()) + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(scope="module") +def pem() -> bytes: + return ed25519.Ed25519PrivateKey.generate().private_bytes( + serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), + ) + + +@pytest.fixture +def venue(): + with respx.mock(base_url=BASE, assert_all_called=False) as mock: + mock.get("/communications/id").respond(json=sample("communications_id")) + yield mock + + +@pytest.fixture +def kalshi(venue, pem): + return k.KalshiTrading( + KalshiCredentials(key_id="key-1", private_key_pem=pem, env="demo"), + client=httpx.AsyncClient(), limiter=BudgetLimiter(read_per_second=1000, write_per_second=1000), + ) + + +def body(route) -> dict: + return json.loads(route.calls.last.request.content) + + +def query(route) -> dict: + return dict(route.calls.last.request.url.params) + + +def accept(**kw) -> AcceptQuote: + return AcceptQuote(**{"quote_id": QUOTE, "rfq_id": RFQ, "outcome": "yes", "action": "buy", **kw}) + + +def legs(**kw) -> list[ComboLeg]: + return [ComboLeg(market_id=f"kalshi:{LEG_A}", outcome="yes", event_id=EVENT_A, **kw), + ComboLeg(market_id=f"kalshi:{LEG_B}", outcome="no", event_id=EVENT_B, **kw)] + + +# -- interface --------------------------------------------------------------------- + + +class TestInterface: + def test_capability_keys(self): + has = k.KalshiTrading.has + assert has["rfq_taker"] is True and has["rfq_quoter"] is True and has["combos"] is True + assert has["rfq"] is True # the older key stays for one release + + @pytest.mark.parametrize("name", [ + "create_combo", "fetch_combo", "lookup_combo", "create_rfq", "fetch_rfq", "fetch_rfqs", "cancel_rfq", + "fetch_quotes", "fetch_quote", "accept_quote", "fetch_incoming_rfqs", "send_quote", "replace_quote", + "cancel_quote", "confirm_quote", "fetch_my_quotes", + ]) + def test_every_shared_method_is_answered(self, name): + assert getattr(k.KalshiTrading, name) is not getattr(TradingExchange, name) + + def test_the_raw_methods_are_gone(self): + assert not hasattr(k.KalshiTrading, "delete_rfq") + + async def test_rfq_calls_are_signed(self, kalshi, venue): + route = venue.get(f"/communications/rfqs/{RFQ}").respond(json=sample("get_rfq")) + await kalshi.fetch_rfq(RFQ) + headers = route.calls.last.request.headers + assert headers["KALSHI-ACCESS-KEY"] == "key-1" and headers["KALSHI-ACCESS-SIGNATURE"] + + +# -- normalisers ------------------------------------------------------------------- + + +class TestQuoteOf: + def test_two_sided_quote_in_yes_prices(self): + quote = k.quote_of(sample("get_quote_open")["quote"], me="comm-self-7f3a") + assert quote.bid == D("0.45") # yes_bid: what we sell YES at + assert quote.ask == D("0.45") # 1 - no_bid 0.55: what we buy YES at + assert quote.bid_quantity == D("5") and quote.ask_quantity == D("5") + assert quote.status == QuoteStatus.OPEN and quote.market_id == f"kalshi:{COMBO}" + assert quote.mine is False and quote.rfq_id == RFQ + assert quote.created_at == 1792594933031 + + def test_a_zero_bid_is_a_declined_side(self): + quote = k.quote_of(sample("get_quote_no_only")["quote"]) + assert quote.bid is None and quote.bid_quantity is None + assert quote.ask == D("0.408") and quote.ask_quantity == D("5") + assert quote.price_for("yes", "buy") == D("0.408") and quote.price_for("no", "buy") is None + + @pytest.mark.parametrize("name, status", [ + ("open", QuoteStatus.OPEN), ("accepted", QuoteStatus.ACCEPTED), ("confirmed", QuoteStatus.CONFIRMED), + ("executed", QuoteStatus.EXECUTED), ("cancelled", QuoteStatus.CANCELED), + ]) + def test_statuses(self, name, status): + assert k.quote_of(sample(f"get_quote_{name}")["quote"]).status == status + + def test_accepted_no_is_a_yes_buy(self): + """P0: the requester that bought YES had sent `accepted_side=no`.""" + assert k.quote_of(sample("get_quote_accepted")["quote"]).accepted_side == Side.BUY + raw = {**sample("get_quote_accepted")["quote"], "accepted_side": "yes"} + assert k.quote_of(raw).accepted_side == Side.SELL + + def test_executed_carries_both_order_ids(self): + quote = k.quote_of(sample("get_quote_executed")["quote"]) + assert quote.taker_order_id == TAKER_ORDER + assert quote.maker_order_id == "01a1b2c3-d4e5-7f60-8a9b-ffffeeee0001" + assert quote.is_terminal + + def test_sizes_by_side_on_an_amount_rfq(self): + quote = k.quote_of(sample("get_quotes_amount")["quotes"][0]) + assert quote.ask_quantity == D("22.72") # yes_contracts_fp: YES the requester can buy + assert quote.bid_quantity == D("20.83") # no_contracts_fp: NO the requester can buy + + def test_mine_from_the_communications_id(self): + raw = sample("get_quotes_mine")["quotes"][0] + assert k.quote_of(raw, me="comm-self-7f3a").mine is True + assert k.quote_of(raw, me="someone").mine is False + + +class TestRfqOf: + def test_a_combo_rfq_with_its_legs(self): + rfq = k.rfq_of(sample("get_rfq")["rfq"], me="comm-self-7f3a") + assert rfq.combo_id == f"kalshi:{COMBO}" and rfq.market_id is None + assert rfq.combo.collection_id == COLL + assert [(l.market_id, l.outcome, l.event_id) for l in rfq.combo.legs] == [ + (f"kalshi:{LEG_A}", "yes", EVENT_A), (f"kalshi:{LEG_B}", "no", EVENT_B)] + assert rfq.quantity == D("5") and rfq.amount is None + assert rfq.mine is True and rfq.status == RfqStatus.OPEN and rfq.outcome is None + + def test_sized_by_target_cost(self): + rfq = k.rfq_of(sample("get_rfq_amount")["rfq"], mine=True) + assert rfq.amount == D("12.5") and rfq.quantity is None + assert rfq.market_id == f"kalshi:{LEG_A}" and rfq.combo_id is None + assert rfq.info["target_cost_excludes_fees"] is True + + def test_closed_reasons(self): + rfq = k.rfq_of(sample("get_rfq_expired")["rfq"], mine=True) + assert rfq.status == RfqStatus.CLOSED and rfq.closed_reason == RfqCloseReason.EXPIRED + cancelled = k.rfq_of(sample("get_rfqs_mine")["rfqs"][1], mine=True) + assert cancelled.closed_reason == RfqCloseReason.CANCELED + + def test_a_kxmve_ticker_alone_marks_a_combo(self): + raw = {**sample("get_rfq")["rfq"], "mve_collection_ticker": "", "mve_selected_legs": []} + rfq = k.rfq_of(raw, mine=True) + assert rfq.combo_id == f"kalshi:{COMBO}" and rfq.combo is None + + +class TestComboOf: + def test_market_to_combo(self): + combo = k.combo_of(sample("get_market_combo")["market"]) + assert combo.market_id == f"kalshi:{COMBO}" and combo.collection_id == COLL + assert combo.status == "settled" and len(combo.legs) == 2 + assert combo.legs[1].outcome == "no" + + def test_a_plain_market_is_not_a_combo(self): + with pytest.raises(RfqNotFound): + k.combo_of(sample("get_market_leg")["market"]) + + +# -- taker ------------------------------------------------------------------------- + + +class TestCreateRfq: + async def test_by_contracts(self, kalshi, venue): + route = venue.post("/communications/rfqs").respond(201, json=sample("create_rfq")) + rfq = await kalshi.create_rfq(f"kalshi:{COMBO}", quantity=D("5")) + assert body(route) == {"market_ticker": COMBO, "rest_remainder": False, "contracts_fp": "5.00"} + assert rfq.id == RFQ and rfq.combo_id == f"kalshi:{COMBO}" and rfq.quantity == D("5") + assert rfq.status == RfqStatus.OPEN and rfq.mine + + async def test_by_amount_as_target_cost(self, kalshi, venue): + route = venue.post("/communications/rfqs").respond(201, json=sample("create_rfq")) + rfq = await kalshi.create_rfq(f"kalshi:{LEG_A}", amount=D("12.50"), rest_remainder=True) + assert body(route) == {"market_ticker": LEG_A, "rest_remainder": True, "target_cost_dollars": "12.5000"} + assert rfq.amount == D("12.50") and rfq.quantity is None and rfq.market_id == f"kalshi:{LEG_A}" + + async def test_target_cost_excludes_fees(self, kalshi, venue): + route = venue.post("/communications/rfqs").respond(201, json=sample("create_rfq")) + await kalshi.create_rfq(LEG_A, amount=D("10"), target_cost_excludes_fees=True) + assert body(route)["target_cost_excludes_fees"] is True + with pytest.raises(InvalidOrder, match="amount"): + await kalshi.create_rfq(LEG_A, quantity=D("5"), target_cost_excludes_fees=True) + + async def test_refusals_send_nothing(self, kalshi, venue): + route = venue.post("/communications/rfqs").respond(201, json=sample("create_rfq")) + with pytest.raises(InvalidOrder, match="two-sided"): + await kalshi.create_rfq(LEG_A, quantity=D("5"), outcome="yes", action="buy") + with pytest.raises(InvalidOrder, match="exactly one"): + await kalshi.create_rfq(LEG_A) + with pytest.raises(InvalidOrder): + await kalshi.create_rfq(LEG_A, quantity=D("1.005")) # off the 0.01 grid + with pytest.raises(InvalidOrder): + await kalshi.create_rfq("polymarket_us:caoc-x", quantity=D("1")) + assert not route.called + + async def test_one_per_market_is_a_rejection(self, kalshi, venue): + venue.post("/communications/rfqs").respond(409, json={"error": {"code": "conflict", "message": "an open RFQ exists"}}) + with pytest.raises(OrderRejected): + await kalshi.create_rfq(LEG_A, quantity=D("5")) + + +class TestReadRfqs: + async def test_fetch_rfq(self, kalshi, venue): + venue.get(f"/communications/rfqs/{RFQ}").respond(json=sample("get_rfq")) + rfq = await kalshi.fetch_rfq(RFQ) + assert isinstance(rfq, Rfq) and rfq.mine and rfq.combo is not None + + async def test_missing_rfq(self, kalshi, venue): + venue.get(f"/communications/rfqs/{RFQ}").respond(404, json={"error": {"code": "not_found", "message": "not found"}}) + with pytest.raises(RfqNotFound): + await kalshi.fetch_rfq(RFQ) + + async def test_fetch_rfqs_is_our_own(self, kalshi, venue): + route = venue.get("/communications/rfqs").respond(json=sample("get_rfqs_mine")) + page = await kalshi.fetch_rfqs(status="open", market_id=f"kalshi:{COMBO}", limit=50) + assert query(route) == {"user_filter": "self", "status": "open", "market_ticker": COMBO, "limit": "50"} + assert [r.mine for r in page] == [True, True] and page.next_cursor is None + + async def test_cancel_rfq(self, kalshi, venue): + route = venue.delete(f"/communications/rfqs/{RFQ}").respond(204) + assert await kalshi.cancel_rfq(RFQ) is None + assert route.called + + +class TestReadQuotes: + async def test_fetch_quotes_on_our_rfq(self, kalshi, venue): + route = venue.get("/communications/quotes").respond(json=sample("get_quotes")) + quotes = await kalshi.fetch_quotes(RFQ, status="canceled") + assert query(route) == {"rfq_user_filter": "self", "rfq_id": RFQ, "status": "cancelled", "limit": "500"} + assert len(quotes) == 2 and all(isinstance(q, RfqQuote) and not q.mine for q in quotes) + assert quotes[1].bid is None + + async def test_rows_for_another_rfq_are_dropped(self, kalshi, venue): + venue.get("/communications/quotes").respond(json=sample("get_quotes_amount")) + assert await kalshi.fetch_quotes(RFQ) == [] + + async def test_pages_are_followed(self, kalshi, venue): + first = {**sample("get_quotes"), "cursor": "next"} + route = venue.get("/communications/quotes").mock(side_effect=[ + httpx.Response(200, json=first), httpx.Response(200, json={"quotes": [], "cursor": ""})]) + assert len(await kalshi.fetch_quotes(RFQ)) == 2 + assert route.call_count == 2 and query(route)["cursor"] == "next" + + async def test_statuses_the_venue_lacks_are_refused(self, kalshi, venue): + with pytest.raises(BadRequest, match="expired"): + await kalshi.fetch_quotes(RFQ, status="expired") + + async def test_fetch_quote(self, kalshi, venue): + venue.get(QUOTE_PATH).respond(json=sample("get_quote_open")) + quote = await kalshi.fetch_quote(QUOTE, rfq_id=RFQ) + assert quote.id == QUOTE and quote.status == QuoteStatus.OPEN + with pytest.raises(InvalidOrder, match="rfq_id"): + await kalshi.fetch_quote(QUOTE) + + async def test_missing_quote(self, kalshi, venue): + venue.get(QUOTE_PATH).respond(404, json={"error": {"code": "not_found", "message": "not found"}}) + with pytest.raises(RfqNotFound): + await kalshi.fetch_quote(QUOTE, rfq_id=RFQ) + + +class TestAccept: + @pytest.mark.parametrize("outcome, action, wire, price", [ + ("yes", "buy", "no", D("0.45")), # P0: buy YES takes the maker's NO bid, at 1 - no_bid + ("no", "sell", "no", D("0.45")), + ("no", "buy", "yes", D("0.45")), # buy NO takes the YES bid, at 1 - yes_bid a NO contract + ("yes", "sell", "yes", D("0.45")), + ]) + async def test_side_mapping(self, kalshi, venue, outcome, action, wire, price): + venue.get(QUOTE_PATH).respond(json=sample("get_quote_open")) + route = venue.put(f"{QUOTE_PATH}/accept").respond(204) + quote = await kalshi.accept_quote(accept(outcome=outcome, action=action)) + assert body(route) == {"accepted_side": wire} + assert quote.status == QuoteStatus.ACCEPTED + assert quote.accepted_side == (Side.BUY if wire == "no" else Side.SELL) + assert quote.price_for(outcome, action) == price + assert quote.info["accept"]["client_order_id"] == f"rfq-accept:kalshi:{QUOTE}" + + async def test_buy_yes_at_1_minus_no_bid(self, kalshi, venue): + venue.get(QUOTE_PATH).respond(json=sample("get_quote_no_only")) + route = venue.put(f"{QUOTE_PATH}/accept").respond(204) + await kalshi.accept_quote(accept(worst_price=D("0.41"))) + assert body(route) == {"accepted_side": "no"} + + async def test_a_declined_side_is_refused_before_sending(self, kalshi, venue): + venue.get(QUOTE_PATH).respond(json=sample("get_quote_no_only")) + route = venue.put(f"{QUOTE_PATH}/accept").respond(204) + with pytest.raises(InvalidOrder, match="yes_bid is 0"): + await kalshi.accept_quote(accept(outcome="no", action="buy")) + assert not route.called + + @pytest.mark.parametrize("outcome, action, worst", [ + ("yes", "buy", D("0.44")), # buying YES at 0.45, worst 0.44 + ("yes", "sell", D("0.46")), # selling YES at 0.45, worst 0.46 + ("no", "buy", D("0.46")), # buying NO is selling YES at 0.45 + ]) + async def test_worse_than_worst_price_is_refused(self, kalshi, venue, outcome, action, worst): + venue.get(QUOTE_PATH).respond(json=sample("get_quote_open")) + route = venue.put(f"{QUOTE_PATH}/accept").respond(204) + with pytest.raises(InvalidOrder, match="worst price"): + await kalshi.accept_quote(accept(outcome=outcome, action=action, worst_price=worst)) + assert not route.called + + async def test_at_the_worst_price_is_sent(self, kalshi, venue): + venue.get(QUOTE_PATH).respond(json=sample("get_quote_open")) + route = venue.put(f"{QUOTE_PATH}/accept").respond(204) + await kalshi.accept_quote(accept(worst_price=D("0.45"))) + assert route.called + + async def test_a_retry_on_the_same_side_does_not_resend(self, kalshi, venue): + venue.get(QUOTE_PATH).respond(json=sample("get_quote_accepted")) + route = venue.put(f"{QUOTE_PATH}/accept").respond(204) + quote = await kalshi.accept_quote(accept()) + assert not route.called and quote.info["accept"]["resent"] is False + with pytest.raises(OrderRejected, match="already accepted"): + await kalshi.accept_quote(accept(outcome="no", action="buy")) + + async def test_a_closed_quote_is_refused(self, kalshi, venue): + venue.get(QUOTE_PATH).respond(json=sample("get_quote_cancelled")) + with pytest.raises(OrderRejected, match="not open"): + await kalshi.accept_quote(accept()) + + async def test_full_size_only_and_rfq_id_required(self, kalshi, venue): + with pytest.raises(InvalidOrder, match="full size"): + await kalshi.accept_quote(accept(quantity=D("2"))) + with pytest.raises(InvalidOrder, match="rfq_id"): + await kalshi.accept_quote(accept(rfq_id=None)) + + async def test_venue_refusal_is_typed(self, kalshi, venue): + venue.get(QUOTE_PATH).respond(json=sample("get_quote_open")) + venue.put(f"{QUOTE_PATH}/accept").respond(400, json={"error": {"code": "invalid_parameters", "message": "invalid parameters"}}) + with pytest.raises(OrderRejected) as caught: + await kalshi.accept_quote(accept()) + assert caught.value.reason == "invalid_parameters" + + +# -- quoter ------------------------------------------------------------------------ + + +class TestQuoter: + async def test_incoming_leaves_ours_out(self, kalshi, venue): + route = venue.get("/communications/rfqs").respond(json=sample("get_rfqs_open")) + page = await kalshi.fetch_incoming_rfqs(market_id=f"kalshi:{LEG_A}") + assert query(route) == {"status": "open", "market_ticker": LEG_A} + assert [r.id for r in page] == [INCOMING] and page[0].mine is False and page[0].quantity == D("20") + + @pytest.mark.parametrize("bid, ask, yes_bid, no_bid", [ + (D("0.40"), D("0.45"), "0.4000", "0.5500"), + (D("0.40"), None, "0.4000", "0.0000"), + (None, D("0.62"), "0.0000", "0.3800"), + ]) + async def test_send_quote_price_mapping(self, kalshi, venue, bid, ask, yes_bid, no_bid): + route = venue.post("/communications/quotes").respond(201, json=sample("create_quote")) + quote = await kalshi.send_quote(MakerQuote(rfq_id=INCOMING, bid=bid, ask=ask, post_only=True)) + assert body(route) == {"rfq_id": INCOMING, "yes_bid": yes_bid, "no_bid": no_bid, + "rest_remainder": False, "post_only": True} + assert quote.id == "4c5d6e7f-8091-4a2b-b3c4-d5e6f7081920" and quote.mine + assert quote.bid == bid and quote.ask == ask and quote.status == QuoteStatus.OPEN + + async def test_send_quote_refuses_what_the_venue_lacks(self, kalshi, venue): + route = venue.post("/communications/quotes").respond(201, json=sample("create_quote")) + with pytest.raises(InvalidOrder, match="quantity"): + await kalshi.send_quote(MakerQuote(rfq_id=INCOMING, bid=D("0.4"), quantity=D("5"))) + with pytest.raises(InvalidOrder, match="expiry"): + await kalshi.send_quote(MakerQuote(rfq_id=INCOMING, bid=D("0.4"), expires_at=1)) + assert not route.called + + async def test_insufficient_balance_is_insufficient_funds(self, kalshi, venue): + """P0: a demo quote with too little balance answered 400 insufficient_balance.""" + venue.post("/communications/quotes").respond(400, json=sample("error_insufficient_balance")) + with pytest.raises(InsufficientFunds): + await kalshi.send_quote(MakerQuote(rfq_id=INCOMING, bid=D("0.01"), ask=D("0.99"))) + + async def test_replace_is_cancel_then_send(self, kalshi, venue): + old = "4c5d6e7f-8091-4a2b-b3c4-d5e6f7081920" + calls: list[str] = [] + venue.delete(f"/communications/rfqs/{INCOMING}/quotes/{old}").mock( + side_effect=lambda request: calls.append("delete") or httpx.Response(204)) + venue.post("/communications/quotes").mock( + side_effect=lambda request: calls.append("post") or httpx.Response(201, json={"id": "new-quote"})) + quote = await kalshi.replace_quote(old, MakerQuote(rfq_id=INCOMING, bid=D("0.41"), ask=D("0.44"))) + assert calls == ["delete", "post"] + assert quote.id == "new-quote" and quote.info["replaces"] == old + + async def test_replace_of_a_quote_already_gone_still_sends(self, kalshi, venue): + old = "gone" + venue.delete(f"/communications/rfqs/{INCOMING}/quotes/{old}").respond( + 404, json={"error": {"code": "not_found", "message": "not found"}}) + route = venue.post("/communications/quotes").respond(201, json={"id": "new-quote"}) + await kalshi.replace_quote(old, MakerQuote(rfq_id=INCOMING, bid=D("0.41"))) + assert route.called + + async def test_cancel_quote(self, kalshi, venue): + route = venue.delete(f"/communications/rfqs/{INCOMING}/quotes/q1").respond(204) + await kalshi.cancel_quote("q1", rfq_id=INCOMING) + assert route.called + with pytest.raises(InvalidOrder, match="rfq_id"): + await kalshi.cancel_quote("q1") + + async def test_confirm_quote(self, kalshi, venue): + route = venue.put(f"/communications/rfqs/{INCOMING}/quotes/q1/confirm").respond(204) + quote = RfqQuote(id="q1", rfq_id=INCOMING, venue="kalshi", bid=D("0.4"), status=QuoteStatus.ACCEPTED, mine=True) + confirmed = await kalshi.confirm_quote(quote) + assert route.called and body(route) == {} + assert confirmed.status == QuoteStatus.CONFIRMED + + async def test_fetch_my_quotes(self, kalshi, venue): + route = venue.get("/communications/quotes").respond(json=sample("get_quotes_mine")) + quotes = await kalshi.fetch_my_quotes(rfq_id=INCOMING, status="open") + assert query(route) == {"user_filter": "self", "rfq_id": INCOMING, "status": "open", "limit": "500"} + assert len(quotes) == 1 and quotes[0].mine and quotes[0].bid == D("0.40") and quotes[0].ask == D("0.45") + + +# -- combos ------------------------------------------------------------------------ + + +class TestCombos: + async def test_collections(self, kalshi, venue): + route = venue.get("/multivariate_event_collections").respond(json=sample("get_collections")) + page = await kalshi.fetch_combo_collections(event_id=f"kalshi:{EVENT_A}", limit=20) + assert query(route) == {"status": "open", "associated_event_ticker": EVENT_A, "limit": "20"} + assert page[0]["collection_ticker"] == COLL and page.next_cursor is None + + async def test_create_in_a_named_collection(self, kalshi, venue): + route = venue.post(f"/multivariate_event_collections/{COLL}").respond(json=sample("create_combo")) + combo = await kalshi.create_combo(legs(), collection_id=COLL) + assert body(route) == {"selected_markets": [ + {"market_ticker": LEG_A, "event_ticker": EVENT_A, "side": "yes"}, + {"market_ticker": LEG_B, "event_ticker": EVENT_B, "side": "no"}, + ], "with_market_payload": True} + assert isinstance(combo, Combo) and combo.market_id == f"kalshi:{COMBO}" + assert combo.collection_id == COLL and combo.status == "open" + assert [l.market_id for l in combo.legs] == [f"kalshi:{LEG_A}", f"kalshi:{LEG_B}"] + + async def test_create_finds_the_one_collection(self, kalshi, venue): + listed = venue.get("/multivariate_event_collections").respond(json=sample("get_collections")) + venue.post(f"/multivariate_event_collections/{COLL}").respond(json=sample("create_combo")) + combo = await kalshi.create_combo(legs()) + assert query(listed)["associated_event_ticker"] == EVENT_A + assert combo.collection_id == COLL + + async def test_a_leg_without_its_event_reads_the_market(self, kalshi, venue): + venue.get(f"/markets/{LEG_B}").respond(json=sample("get_market_leg")) + route = venue.post(f"/multivariate_event_collections/{COLL}").respond( + json={"event_ticker": "E", "market_ticker": COMBO}) + bare = [legs()[0], ComboLeg(market_id=f"kalshi:{LEG_B}", outcome="no")] + combo = await kalshi.create_combo(bare, collection_id=COLL) + assert body(route)["selected_markets"][1]["event_ticker"] == EVENT_B + assert combo.legs[1].event_id == EVENT_B # no market payload: the legs sent + + async def test_no_or_several_collections_are_refused(self, kalshi, venue): + two = sample("get_collections") + two["multivariate_contracts"].append({**two["multivariate_contracts"][0], "collection_ticker": "KXMVEOTHER-R"}) + venue.get("/multivariate_event_collections").mock(side_effect=[ + httpx.Response(200, json=two), httpx.Response(200, json={"multivariate_contracts": []})]) + with pytest.raises(InvalidOrder, match="2 open collections") as refused: + await kalshi.create_combo(legs()) + assert f"{COLL}, KXMVEOTHER-R" in str(refused.value) and "collection_id" in str(refused.value) + assert refused.value.collections == [COLL, "KXMVEOTHER-R"] + with pytest.raises(InvalidOrder, match="no open collection"): + await kalshi.create_combo(legs()) + + async def test_the_three_live_collections_are_ambiguous_until_named(self, kalshi, venue): + """Kalshi's three open collections take the same events (2026-10-22), + so legs alone never pick one; naming it skips the listing.""" + three = sample("get_collections") + row = three["multivariate_contracts"][0] + names = ["KXMVESPORTSMULTIGAMEEXTENDED-R", "KXMVECROSSCATEGORY-R", "KXMVECROSSCATEGORY-SHARD1-R"] + three["multivariate_contracts"] = [{**row, "collection_ticker": n} for n in names] + listed = venue.get("/multivariate_event_collections").respond(json=three) + with pytest.raises(InvalidOrder, match="3 open collections") as refused: + await kalshi.create_combo(legs()) + assert all(n in str(refused.value) for n in names) + made = venue.post(f"/multivariate_event_collections/{names[2]}").respond(json=sample("create_combo")) + before = listed.call_count + combo = await kalshi.create_combo(legs(), collection_id=names[2]) + assert made.called and listed.call_count == before, "a named collection is used as given" + assert combo.collection_id in (names[2], COLL) + + async def test_collections_always_carry_their_event_tickers(self, kalshi, venue): + bare = sample("get_collections") + del bare["multivariate_contracts"][0]["associated_event_tickers"] + venue.get("/multivariate_event_collections").respond(json=bare) + page = await kalshi.fetch_combo_collections() + assert page[0]["associated_event_tickers"] == [EVENT_A, EVENT_B] + + async def test_refused_legs_are_invalid(self, kalshi, venue): + venue.post(f"/multivariate_event_collections/{COLL}").respond( + 400, json={"error": {"code": "invalid_parameters", "message": "markets cannot be combined"}}) + with pytest.raises(InvalidOrder, match="cannot be combined"): + await kalshi.create_combo(legs(), collection_id=COLL) + + async def test_too_few_or_repeated_legs(self, kalshi, venue): + with pytest.raises(InvalidOrder, match="two legs"): + await kalshi.create_combo(legs()[:1], collection_id=COLL) + with pytest.raises(InvalidOrder, match="once"): + await kalshi.create_combo([legs()[0], legs()[0]], collection_id=COLL) + + async def test_fetch_combo(self, kalshi, venue): + venue.get(f"/markets/{COMBO}").respond(json=sample("get_market_combo")) + combo = await kalshi.fetch_combo(f"kalshi:{COMBO}") + assert combo.market_id == f"kalshi:{COMBO}" and combo.status == "settled" + + async def test_fetch_combo_missing_or_not_a_combo(self, kalshi, venue): + venue.get(f"/markets/{COMBO}").respond(404, json={"error": {"code": "not_found", "message": "not found"}}) + with pytest.raises(RfqNotFound): + await kalshi.fetch_combo(COMBO) + venue.get(f"/markets/{LEG_B}").respond(json=sample("get_market_leg")) + with pytest.raises(RfqNotFound, match="not a combo"): + await kalshi.fetch_combo(LEG_B) + + async def test_no_lookup_by_legs(self, kalshi): + with pytest.raises(NotSupported, match="create_combo"): + await kalshi.lookup_combo(legs()) + + +# -- error mapping ----------------------------------------------------------------- + + +class TestErrors: + def test_not_found_under_communications_is_rfq_not_found(self): + err = k.map_error(MarketNotFound("kalshi: not found", status=404), path=f"/communications/rfqs/{RFQ}") + assert isinstance(err, RfqNotFound) + err = k.map_error(MarketNotFound("kalshi: not found", status=404), path="/multivariate_event_collections/X") + assert isinstance(err, RfqNotFound) + + def test_insufficient_balance(self): + err = k.map_error(BadRequest("kalshi: insufficient balance", body=sample("error_insufficient_balance"), status=400), + path="/communications/quotes") + assert isinstance(err, InsufficientFunds) + + def test_order_paths_are_unchanged(self): + from synpath.trading.errors import OrderNotFound + err = k.map_error(MarketNotFound("kalshi: not found", status=404), path="/portfolio/orders/x") + assert isinstance(err, OrderNotFound) and not isinstance(err, RfqNotFound) + + +# -- through the engine ------------------------------------------------------------ + + +class TestEngine: + async def test_accept_to_order_fill_and_position(self, kalshi, venue, tmp_path): + """`engine.rfq` on the Kalshi adapter: create a combo RFQ, read the + maker's quote, accept YES (sent as `no`), follow the quote to + executed, adopt `rfq_creator_order_id` and book its fill.""" + from synpath.engine import Engine, EngineConfig, RiskConfig + from synpath.engine.storage_types import IntentState + from synpath.trading.types import PositionSide + + state = {"quote": "open"} + + def quote_now(request): + name = {"open": "get_quote_open", "accepted": "get_quote_accepted", "executed": "get_quote_executed"} + return httpx.Response(200, json=sample(name[state["quote"]])) + + def accepted(request): + assert json.loads(request.content) == {"accepted_side": "no"} + state["quote"] = "accepted" + return httpx.Response(204) + + venue.post("/communications/rfqs").respond(201, json=sample("create_rfq")) + venue.get("/communications/quotes").respond(json={"quotes": [sample("get_quote_open")["quote"]], "cursor": ""}) + venue.get(QUOTE_PATH).mock(side_effect=quote_now) + venue.put(f"{QUOTE_PATH}/accept").mock(side_effect=accepted) + venue.get(f"/portfolio/orders/{TAKER_ORDER}").respond(json=sample("get_order_rfq")) + venue.get("/portfolio/fills").respond(json=sample("get_fills_rfq")) + venue.get("/portfolio/orders").respond(json={"orders": [], "cursor": ""}) + venue.get("/portfolio/positions").respond(json={"market_positions": [], "cursor": ""}) + venue.get("/portfolio/balance").respond(json={"balance": 10000, "balance_dollars": "100.0000"}) + + engine = Engine( + {"kalshi": kalshi}, + EngineConfig(journal_path=str(tmp_path / "engine.db"), in_doubt_timeout_s=0, + rfq_poll_interval_s=0.01, rfq_incoming_interval_s=None), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None), + ) + await engine.start() + try: + combo = Combo(market_id=f"kalshi:{COMBO}", venue="kalshi", collection_id=COLL, legs=legs()) + rfq = await engine.rfq.create_rfq(f"kalshi:{COMBO}", quantity=D("5"), combo=combo, book="alpha") + assert rfq.id == RFQ and rfq.combo_id == f"kalshi:{COMBO}" + quotes = await kalshi.fetch_quotes(RFQ) + result = await engine.rfq.accept_quote( + AcceptQuote(quote_id=quotes[0].id, rfq_id=RFQ, outcome="yes", action="buy", + worst_price=D("0.46"), book="alpha")) + assert result.status == QuoteStatus.ACCEPTED and result.accepted_side == Side.BUY + + state["quote"] = "executed" + await engine.rfq.tick() + intent = await engine.journal.intent(f"rfq-accept:kalshi:{QUOTE}") + assert intent.state == IntentState.SENT and intent.order_id == TAKER_ORDER + order = await engine.journal.order("kalshi", TAKER_ORDER) + assert order.side == Side.BUY and order.filled == D("5") and order.tags["quote_id"] == QUOTE + [position] = engine.positions() + assert (position.market_id, position.side, position.contracts, position.entry_price) == \ + (f"kalshi:{COMBO}", PositionSide.LONG, D("5"), D("0.45")) + assert position.info["book"] == "alpha" and len(position.info["combo"]["legs"]) == 2 + finally: + await engine.stop() + + +# -- serve route ------------------------------------------------------------------- + + +class TestCollectionsRoute: + async def test_get_combo_collections(self, kalshi, venue, tmp_path): + from fastapi.testclient import TestClient + + from synpath.engine import Engine, EngineConfig, RiskConfig + from synpath.engine.paper import PaperRfqVenue + from synpath.server.store import ControlStore + from synpath.server.trading import create_trading_app + from synpath.trading.types import Account + + venue.get("/portfolio/orders").respond(json={"orders": [], "cursor": ""}) + venue.get("/portfolio/positions").respond(json={"market_positions": [], "cursor": ""}) + venue.get("/portfolio/balance").respond(json={"balance": 10000, "balance_dollars": "100.0000"}) + route = venue.get("/multivariate_event_collections").respond(json=sample("get_collections")) + account = Account(venue="kalshi", name="default") + us = Account(venue="polymarket_us", name="default") + engine = Engine( + {"kalshi": kalshi, "polymarket_us": PaperRfqVenue(venue="polymarket_us", account=us)}, + EngineConfig(journal_path=str(tmp_path / "engine.db"), require_lease=True, + rfq_incoming_interval_s=None), + risk=RiskConfig(price_collar=None), accounts={"kalshi": account, "polymarket_us": us}, + ) + await engine.start() + store = await ControlStore(str(tmp_path / "control.db")).open() + try: + owner, _ = await store.bootstrap("owner") + await store.acting_as(f"user:{owner.id}", "setup") + viewer = await store.create_user("viewer") + await store.grant(viewer.id, account.key, "view") + key = (await store.issue_key(viewer.id)).secret + outsider = await store.create_user("outsider") + await store.grant(outsider.id, us.key, "view") + other = (await store.issue_key(outsider.id)).secret + with TestClient(create_trading_app(engine, store)) as client: + answer = client.get("/combos/collections", params={"event_id": f"kalshi:{EVENT_A}"}, + headers={"Authorization": f"Bearer {key}"}) + assert answer.status_code == 200, answer.text + assert answer.json()["data"][0]["collection_ticker"] == COLL + assert query(route)["associated_event_ticker"] == EVENT_A + refused = client.get("/combos/collections", headers={"Authorization": f"Bearer {other}"}) + assert refused.status_code == 403 + missing = client.get("/combos/collections", params={"venue": "polymarket_us"}, + headers={"Authorization": f"Bearer {other}"}) + assert missing.status_code == 501 + finally: + await store.close() + await engine.stop() diff --git a/tests/test_kalshi_stop_fill.py b/tests/test_kalshi_stop_fill.py new file mode 100644 index 0000000..e946338 --- /dev/null +++ b/tests/test_kalshi_stop_fill.py @@ -0,0 +1,169 @@ +"""Natural-crossing and filled stop verification, using mocked HTTP only.""" +import json +from decimal import Decimal as D + +import httpx +import pytest + +from examples.verify_kalshi_stop_fill import FillGuard, book_for, verify +from synpath.trading.kalshi import KalshiTrading +from test_kalshi_trading import creds, pem, rsa_key + +pytestmark = pytest.mark.anyio +TICKER = "KXBTC15M-TEST-00" + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +class FillVenue: + def __init__(self, *, crossing=True, lose_response=False, rejected=False): + self.crossing, self.lose_response = crossing, lose_response + self.rejected = rejected + self.books = 0 + self.orders = {} + self.posts = [] + self.position = D("0") + + def handle(self, request): + path = request.url.path.removeprefix("/trade-api/v2") + if path == f"/markets/{TICKER}/orderbook": + self.books += 1 + ask = "0.42" if self.crossing and self.books > 1 else "0.40" + return httpx.Response(200, json={"orderbook_fp": {"yes_dollars": [["0.39", "10"]], + "no_dollars": [[str(1-D(ask)), "10"]]}}) + if request.method == "POST" and path == "/portfolio/events/orders": + body = json.loads(request.content) + self.posts.append(body) + if self.rejected: + return httpx.Response(400, json={"error": {"code": "insufficient_balance", "message": "insufficient shard balance"}}) + entry = body["side"] == "bid" + oid = "entry" if entry else "exit" + price = D("0.42") if entry else D("0.39") + self.position += D("1") if entry else -D(body["count"]) + self.orders[oid] = {"order_id": oid, "client_order_id": body["client_order_id"], "ticker": TICKER, + "book_side": body["side"], "status": "executed", "initial_count_fp": "1", + "fill_count_fp": "1", "remaining_count_fp": "0", "yes_price_dollars": body["price"], + "outcome_side": "yes" if entry else "no", + "taker_fill_cost_dollars": str(price if entry else 1 - price), "taker_fees_dollars": "0.02"} + if entry and self.lose_response: + raise httpx.ReadError("accepted; response lost", request=request) + return httpx.Response(201, json={"order_id": oid, "client_order_id": body["client_order_id"], + "fill_count": "1", "remaining_count": "0", "average_fill_price": str(price)}) + if request.method == "GET" and path.startswith("/portfolio/orders/"): + return httpx.Response(200, json={"order": self.orders[path.split("/")[-1]]}) + if request.method == "GET" and path == "/portfolio/orders": + return httpx.Response(200, json={"orders": list(self.orders.values()), "cursor": ""}) + if request.method == "GET" and path == "/portfolio/positions": + assert self.position == 0 + return httpx.Response(200, json={"market_positions": []}) + if request.method == "GET" and path == "/portfolio/fills": + return httpx.Response(200, json={"fills": [{"fill_id": "fill-1", "order_id": "entry", "ticker": TICKER, + "book_side": "bid", "count_fp": "1", "yes_price_dollars": "0.42", + "fee_cost": "0.02", "is_taker": True}]}) + raise AssertionError(f"unexpected operation {request.method} {path}") + + +@pytest.mark.parametrize("lose_response", [False, True]) +async def test_real_price_crossing_fills_once_then_closes_only_test_quantity(creds, tmp_path, lose_response): + venue, guard = FillVenue(lose_response=lose_response), FillGuard(TICKER, D("0.44")) + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle), event_hooks={"request": [guard.check], "response": [guard.observe]}) as client: + async with KalshiTrading(creds, base_url="https://external-api.kalshi.com/trade-api/v2", client=client) as adapter: + book = await book_for(adapter, TICKER) + report = await verify(adapter, guard, tmp_path, {"ticker": TICKER}, book, D("0.41"), D("0.44"), wait_s=2, poll_s=0) + assert report["outcome"] == "filled and closed" and report["terminal_restart_verified"] + assert report["initial_ask"] == "0.40" and report["observed_trigger_ask"] == "0.42" + assert report["entry_filled"] == "1" and report["entry_average_price"] == "0.4200" + assert report["exit_filled"] == "1" and report["remaining_position_contracts"] == "0" + assert report["exit_average_price"] == "0.3900" + assert len(venue.posts) == 2 and "reduce_only" not in venue.posts[1] and venue.posts[1]["side"] == "ask" + assert venue.posts[1]["count"] == "1.00" and venue.position == 0 + + +async def test_timeout_cancels_local_parent_without_a_venue_order(creds, tmp_path): + venue, guard = FillVenue(crossing=False), FillGuard(TICKER, D("0.44")) + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle), event_hooks={"request": [guard.check], "response": [guard.observe]}) as client: + async with KalshiTrading(creds, base_url="https://external-api.kalshi.com/trade-api/v2", client=client) as adapter: + report = await verify(adapter, guard, tmp_path, {"ticker": TICKER}, await book_for(adapter, TICKER), + D("0.41"), D("0.44"), wait_s=0.5, poll_s=0.01) + assert report["creates_attempted"] == 0 and venue.posts == [] + + +@pytest.mark.parametrize("change", [{"count": "2"}, {"price": "0.45"}, {"side": "ask"}, {"reduce_only": True}, + {"expiration_time": None}]) +async def test_fill_guard_refuses_broader_entry(change): + guard = FillGuard(TICKER, D("0.44")) + guard.stage = "entry" + body = dict(ticker=TICKER, side="bid", price="0.44", count="1", time_in_force="good_till_canceled", + expiration_time=1_900_000_000, post_only=False, client_order_id="entry") + body.update(change) + with pytest.raises(ValueError): + await guard.check(httpx.Request("POST", "https://external-api.kalshi.com/trade-api/v2/portfolio/events/orders", json=body)) + assert not guard.sent + + +async def test_fill_guard_forbids_extra_entry_and_oversized_exit(): + guard = FillGuard(TICKER, D("0.44")) + guard.stage = "entry" + body = dict(ticker=TICKER, side="bid", price="0.44", count="1", time_in_force="good_till_canceled", + expiration_time=1_900_000_000, post_only=False, client_order_id="entry") + request = httpx.Request("POST", "https://external-api.kalshi.com/trade-api/v2/portfolio/events/orders", json=body) + await guard.check(request) + with pytest.raises(ValueError): + await guard.check(request) + guard.stage, guard.exit_quantity = "exit", D("0.50") + body.update(side="ask", price="0.001", client_order_id="exit", time_in_force="immediate_or_cancel") + body.pop("expiration_time") + with pytest.raises(ValueError): + await guard.check(httpx.Request("POST", str(request.url), json=body)) + + +async def test_definitive_shard_refusal_is_not_reported_as_lost_acceptance(creds, tmp_path): + venue, guard = FillVenue(rejected=True), FillGuard(TICKER, D("0.44")) + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle), + event_hooks={"request": [guard.check], "response": [guard.observe]}) as client: + async with KalshiTrading(creds, base_url="https://external-api.kalshi.com/trade-api/v2", client=client) as adapter: + report = await verify(adapter, guard, tmp_path, {"ticker": TICKER}, await book_for(adapter, TICKER), + D("0.41"), D("0.44"), wait_s=2, poll_s=0) + assert report["outcome"] == "venue rejected the entry; no fill" and report["cleanup_verified"] + assert len(venue.posts) == 1 and venue.orders == {} and venue.position == 0 + + +async def test_price_wait_renews_lease_before_sending(creds, tmp_path, monkeypatch): + import asyncio + import examples.verify_kalshi_stop_fill as check + venue, guard = FillVenue(), FillGuard(TICKER, D("0.44")) + original_engine = check.engine_for + def brief_lease(adapter, path): + engine = original_engine(adapter, path) + # Wide margins, so a slow CI runner cannot drop the lease by itself. + engine.config.lease_ttl_ms = 600 + engine.config.lease_renew_s = 0.05 + return engine + original_book = check.book_for + async def slow_book(adapter, ticker): + await asyncio.sleep(0.9) # longer than the lease: only its renewal keeps it + return await original_book(adapter, ticker) + monkeypatch.setattr(check, "engine_for", brief_lease) + monkeypatch.setattr(check, "book_for", slow_book) + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle), + event_hooks={"request": [guard.check], "response": [guard.observe]}) as client: + async with KalshiTrading(creds, base_url="https://external-api.kalshi.com/trade-api/v2", client=client) as adapter: + report = await check.verify(adapter, guard, tmp_path, {"ticker": TICKER}, await original_book(adapter, TICKER), + D("0.41"), D("0.44"), wait_s=2, poll_s=0) + assert report["outcome"] == "filled and closed" and len(venue.posts) == 2 + assert venue.posts[1]["price"] == "0.0100", "exit price is legal on a cent-grid market" + + +def test_kalshi_file_credentials_override_process_environment(pem, tmp_path, monkeypatch): + from examples.verify_kalshi_stop_limit import credentials_from + (tmp_path / "key.pem").write_bytes(pem) + (tmp_path / ".env").write_text("KALSHI_KEY_ID=file-key\nKALSHI_PRIVATE_KEY_PATH=key.pem\nKALSHI_ENV=prod\n") + monkeypatch.setenv("KALSHI_KEY_ID", "system-key") + monkeypatch.setenv("KALSHI_PRIVATE_KEY_PATH", "/missing/system.pem") + monkeypatch.setenv("KALSHI_ENV", "demo") + credential = credentials_from(tmp_path) + assert credential.key_id == "file-key" and credential.env == "prod" + assert credential.private_key_pem == pem diff --git a/tests/test_kalshi_stop_verification.py b/tests/test_kalshi_stop_verification.py new file mode 100644 index 0000000..d3c3b54 --- /dev/null +++ b/tests/test_kalshi_stop_verification.py @@ -0,0 +1,130 @@ +"""Bounded stop-limit lifecycle through the real Kalshi adapter, mocked HTTP.""" +from __future__ import annotations + +import json +from decimal import Decimal as D + +import httpx +import pytest + +from examples.verify_kalshi_stop_limit import MARKET, TICKER, OneOrderGuard, verify +from synpath.errors import BadRequest +from synpath.trading.kalshi import KalshiTrading +from test_kalshi_trading import creds, pem, rsa_key + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +class MockKalshi: + def __init__(self, *, lose_response=False, delayed_reads=0): + self.orders = {} + self.creates = [] + self.cancels = [] + self.lose_response = lose_response + self.delayed_reads = delayed_reads + + def handle(self, request): + path = request.url.path.removeprefix("/trade-api/v2") + if request.method == "GET" and path == f"/markets/{TICKER}": + return httpx.Response(200, json={"market": {"status": "active", "price_level_structure": "linear_cent"}}) + if request.method == "GET" and path == "/series/KXFEDDECISION": + return httpx.Response(200, json={"series": {"fee_type": "quadratic_with_maker_fees", "fee_multiplier": 1}}) + if request.method == "GET" and path == f"/markets/{TICKER}/orderbook": + return httpx.Response(200, json={"orderbook_fp": {"yes_dollars": [["0.53", "20"]], "no_dollars": [["0.44", "20"]]}}) + if request.method == "POST" and path == "/portfolio/events/orders": + body = json.loads(request.content) + self.creates.append(body) + self.orders["test-child"] = { + "order_id": "test-child", "client_order_id": body["client_order_id"], "ticker": TICKER, + "book_side": body["side"], "type": "limit", "status": "resting", "initial_count_fp": body["count"], + "fill_count_fp": "0", "remaining_count_fp": body["count"], "yes_price_dollars": body["price"], + } + if self.lose_response: + raise httpx.ReadError("accepted but response lost", request=request) + return httpx.Response(201, json={"order_id": "test-child", "client_order_id": body["client_order_id"], + "fill_count": "0", "remaining_count": body["count"]}) + if request.method == "DELETE" and path == "/portfolio/events/orders/test-child": + self.cancels.append("test-child") + self.orders["test-child"].update(status="canceled", remaining_count_fp="0") + return httpx.Response(200, json={"order_id": "test-child", "reduced_by": "1"}) + if request.method == "GET" and path == "/portfolio/orders/test-child": + if self.delayed_reads: + self.delayed_reads -= 1 + return httpx.Response(404, json={"error": {"code": "not_found", "message": "read projection not ready"}}) + return httpx.Response(200, json={"order": self.orders["test-child"]}) + if request.method == "GET" and path == "/portfolio/orders": + return httpx.Response(200, json={"orders": list(self.orders.values()), "cursor": ""}) + raise AssertionError(f"unexpected mock call: {request.method} {path}") + + +@pytest.mark.parametrize("delayed_reads", [0, 4]) +async def test_stop_limit_adapter_arms_restores_triggers_and_cleans_only_its_child(creds, tmp_path, delayed_reads): + venue, guard = MockKalshi(delayed_reads=delayed_reads), OneOrderGuard() + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle), + event_hooks={"request": [guard.check]}) as client: + async with KalshiTrading(creds, base_url="https://external-api.kalshi.com/trade-api/v2", client=client) as adapter: + report = await verify(adapter, guard, tmp_path) + assert report["waiting_restart_verified"] and report["terminal_restart_verified"] + assert report["trigger_and_child_verified"] and report["cleanup_verified"] + assert report["filled"] == "0" and report["parent_status"] == "canceled" + assert len(venue.creates) == 1 and venue.cancels == ["test-child"] + sent = venue.creates[0] + assert sent["price"] == "0.0100" and sent["count"] == "1.00" and sent["side"] == "bid" + assert sent["post_only"] is True and sent["expiration_time"] > 0 + assert sent["time_in_force"] == "good_till_canceled" + + +async def test_response_lost_still_finds_and_cancels_only_the_accepted_child(creds, tmp_path): + venue, guard = MockKalshi(lose_response=True), OneOrderGuard() + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle), + event_hooks={"request": [guard.check]}) as client: + async with KalshiTrading(creds, base_url="https://external-api.kalshi.com/trade-api/v2", client=client) as adapter: + with pytest.raises(RuntimeError, match="trigger did not create"): + await verify(adapter, guard, tmp_path) + report = json.loads((tmp_path / "result.json").read_text()) + assert report["cleanup_verified"] and report["venue_status"] == "canceled" + assert len(venue.creates) == 1 and venue.cancels == ["test-child"] + + +@pytest.mark.parametrize("change", [{"count": "2"}, {"price": "0.02"}, {"side": "ask"}, + {"post_only": False}, {"reduce_only": True}, {"subaccount": 1}]) +async def test_wire_guard_refuses_any_broader_order(change): + guard = OneOrderGuard() + guard.enabled = True + body = dict(ticker=TICKER, side="bid", count="1", price="0.01", post_only=True, + time_in_force="good_till_canceled", expiration_time=int(guard.created_at + 85), client_order_id="tiny-test") + body.update(change) + with pytest.raises(ValueError, match="unexpected size"): + await guard.check(httpx.Request("POST", "https://external-api.kalshi.com/trade-api/v2/portfolio/events/orders", json=body)) + assert guard.sent is None + + +async def test_wire_guard_refuses_repeat_submission_and_unrelated_cancel(): + guard = OneOrderGuard() + guard.enabled = True + request = httpx.Request("POST", "https://external-api.kalshi.com/trade-api/v2/portfolio/events/orders", + json=dict(ticker=TICKER, side="bid", count="1", price="0.01", post_only=True, + time_in_force="good_till_canceled", + expiration_time=int(guard.created_at + 85), client_order_id="tiny-test")) + await guard.check(request) + with pytest.raises(ValueError, match="single-order authorization"): + await guard.check(request) + with pytest.raises(ValueError, match="single-order authorization"): + await guard.check(httpx.Request("DELETE", "https://external-api.kalshi.com/trade-api/v2/portfolio/events/orders/someone-elses")) + + +@pytest.mark.parametrize("tif", ["ioc", "fok"]) +def test_a_stop_limit_cannot_be_immediate_at_all(tif): + """One time in force for the whole order: a stop that must live until it + triggers cannot be immediate-or-cancel, post-only or not.""" + from pydantic import ValidationError + from synpath.trading.types import OrderRequest + for post_only in (True, False): + with pytest.raises(ValidationError, match="how long the whole order lives"): + OrderRequest(market_id=MARKET, side="buy", quantity="1", type="stop_limit", + stop_price="0.01", price="0.01", post_only=post_only, time_in_force=tif) diff --git a/tests/test_kalshi_trading.py b/tests/test_kalshi_trading.py index 6281690..22ceca3 100644 --- a/tests/test_kalshi_trading.py +++ b/tests/test_kalshi_trading.py @@ -132,7 +132,7 @@ def test_defaults(self): body = translate_order(req()) assert body["time_in_force"] == "good_till_canceled" assert body["self_trade_prevention_type"] == "taker_at_cross" - assert body["post_only"] is False and body["reduce_only"] is False + assert body["post_only"] is False and "reduce_only" not in body assert len(body["client_order_id"]) == 36 assert "expiration_time" not in body and "subaccount" not in body @@ -575,3 +575,34 @@ async def test_budget_is_drawn_per_order_in_a_batch(self, creds, venue): async def test_context_manager_closes(self, creds, venue): async with KalshiTrading(creds, client=venue.client()) as k: assert repr(k) == "" + + +@pytest.mark.parametrize("outcome,side,book_side,cost,expected", [ + ("no", "yes", "ask", "0.53", "0.47"), # captured native close read-back + ("yes", "yes", "bid", "0.49", "0.49"), + (None, "yes", "ask", "0.47", "0.47"), # legacy YES sale + (None, "no", "ask", "0.53", "0.47"), # legacy NO-priced order +]) +def test_read_projection_normalizes_priced_outcome_to_yes_average(outcome, side, book_side, cost, expected): + raw = {"order_id": "captured-close", "ticker": "KXNHLGAME-TEST", "book_side": book_side, + "side": side, "action": "sell", "fill_count_fp": "1.00", "initial_count_fp": "1.00", + "remaining_count_fp": "0", "status": "executed", "yes_price_dollars": "0.01", + "taker_fill_cost_dollars": cost, "outcome_side": outcome} + order = order_of(raw) + assert order.average_price == D(expected) + assert order.cost == D(cost), "retain the venue's native outcome notional separately" + assert order.price == D("0.01"), "the submitted limit is not the fill average" + + +def test_no_average_combines_partial_maker_and_taker_costs(): + order = order_of({"book_side": "ask", "outcome_side": "no", "fill_count_fp": "3", + "remaining_count_fp": "2", "maker_fill_cost_dollars": "0.53", + "taker_fill_cost_dollars": "1.06"}) + assert order.average_price == D("0.47") and order.filled == D("3") + + +def test_create_response_average_stays_on_yes_leg_for_an_ask(): + order = order_from_response({"order_id": "close", "fill_count": "1", "remaining_count": "0", + "average_fill_price": "0.47"}, + body={"ticker": "KXNHLGAME-TEST", "side": "ask", "price": "0.01"}) + assert order.average_price == D("0.47") and order.side == Side.SELL diff --git a/tests/test_limitless_trading.py b/tests/test_limitless_trading.py index 7dc7693..22667a4 100644 --- a/tests/test_limitless_trading.py +++ b/tests/test_limitless_trading.py @@ -104,8 +104,8 @@ def test_a_sell_buys_no_at_the_complement(self): order = signed(request(side=Side.SELL, price=Decimal("0.3")))[0]["order"] assert order["tokenId"] == INFO.no_token and order["side"] == 0 and order["price"] == 0.7 - def test_reduce_only_sells_the_token_held(self): - order = signed(request(side=Side.SELL, reduce_only=True))[0]["order"] + def test_selling_yes_sells_the_token_held(self): + order = signed(request(side=Side.SELL, outcome="yes", action="sell"))[0]["order"] assert order["tokenId"] == INFO.yes_token and order["side"] == 1 def test_market_and_ioc_are_fill_and_kill_and_post_only_rests(self): diff --git a/tests/test_limitless_ws.py b/tests/test_limitless_ws.py index a59daa1..cf6634a 100644 --- a/tests/test_limitless_ws.py +++ b/tests/test_limitless_ws.py @@ -51,7 +51,7 @@ def run(stream, *frames, then=None): async def go(): stream._ws = FakeSocket() for raw in frames: - stream._dispatch(raw) + stream._process_raw_frame_and_emit_events(raw) await asyncio.sleep(0) if then is not None: await then diff --git a/tests/test_market_data_keys.py b/tests/test_market_data_keys.py index e0787f5..e8d6247 100644 --- a/tests/test_market_data_keys.py +++ b/tests/test_market_data_keys.py @@ -108,8 +108,9 @@ async def test_serve_hands_the_read_app_the_keys_from_its_dotenv(self, tmp_path: journal=str(tmp_path / "s.db"), control=str(tmp_path / "c.db"), dotenv=str(elsewhere), streams=False, adapters={}, home_dir=str(tmp_path / "home")) try: - async with httpx.AsyncClient(transport=httpx.ASGITransport(app=stack.app), - base_url="http://synpath") as client: + # serve's read routes take its access token, as /trading does. + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=stack.app), base_url="http://synpath", + headers={"Authorization": f"Bearer {stack.owner_key}"}) as client: answer = await client.get("/venues/predict_fun/markets", params={"query": "bitcoin", "limit": 5}) finally: await serve.shutdown(stack) diff --git a/tests/test_opinion_trading.py b/tests/test_opinion_trading.py index aa05e39..780906f 100644 --- a/tests/test_opinion_trading.py +++ b/tests/test_opinion_trading.py @@ -108,8 +108,8 @@ def test_a_sell_buys_the_no_token_at_the_complement(self, signer, info): assert body["tokenId"] == info.no_token and body["side"] == "0" and body["price"] == "0.45" assert contracts == D("100") - def test_reduce_only_sells_the_tokens_held(self, signer, info): - body, contracts = build_signed_order(request(side=Side.SELL, price=D("0.6"), reduce_only=True), info, + def test_selling_yes_sells_the_tokens_held(self, signer, info): + body, contracts = build_signed_order(request(side=Side.SELL, price=D("0.6"), outcome="yes", action="sell"), info, signer=signer, maker=SAFE, now_s=1, salt=5) assert body["tokenId"] == info.yes_token and body["side"] == "1" assert body["makerAmount"] == str(100 * 10**18) and contracts == D("100") diff --git a/tests/test_opinion_ws.py b/tests/test_opinion_ws.py index 5873f26..eda079c 100644 --- a/tests/test_opinion_ws.py +++ b/tests/test_opinion_ws.py @@ -102,7 +102,7 @@ def test_the_key_is_in_the_url(self, catalog): assert market_stream(catalog).url == f"wss://ws.opinion.trade?apikey={KEY}" async def test_the_key_never_reaches_a_status_event(self, catalog): - stream = market_stream(catalog, connect=Connector(OSError(f"cannot reach wss://ws.opinion.trade?apikey={KEY}"))) + stream = market_stream(catalog, websocket_connection_opener=Connector(OSError(f"cannot reach wss://ws.opinion.trade?apikey={KEY}"))) stream.start() await wait_for(lambda: not stream._queue.empty()) await stream.close() @@ -114,7 +114,7 @@ async def test_the_key_never_reaches_a_status_event(self, catalog): class TestBooks: async def test_subscribe_then_snapshot_both_sides(self, catalog, binary): conn = FakeConnection() - stream = market_stream(catalog, connect=Connector(conn)) + stream = market_stream(catalog, websocket_connection_opener=Connector(conn)) await stream.watch_order_book([binary.id]) assert catalog.reads == [] # not connected: nothing to snapshot against stream.start() @@ -137,10 +137,10 @@ async def test_changes_apply_as_the_level_size(self, catalog, binary): await stream.watch_order_book([binary.id]) drain(stream) yes = binary.yes.venue_token_id - [event] = stream.handle(depth(binary, yes, "bids", "0.67", "40")) + [event] = stream._handle_decoded_message(depth(binary, yes, "bids", "0.67", "40")) assert event.kind == "delta" and event.side == "yes" assert event.bids[0].price == D("0.67") and event.best_bid == D("0.67") - stream.handle(depth(binary, yes, "bids", "0.67", "0")) + stream._handle_decoded_message(depth(binary, yes, "bids", "0.67", "0")) assert stream.book(binary.id).best_bid == D("0.66") async def test_changes_during_the_snapshot_are_replayed(self, binary, catalog): @@ -150,7 +150,7 @@ async def test_changes_during_the_snapshot_are_replayed(self, binary, catalog): stream._ws = FakeConnection() task = asyncio.get_running_loop().create_task(stream.watch_order_book([binary.id])) await wait_for(lambda: binary.yes.venue_token_id in stream._pending) - stream.handle(depth(binary, binary.yes.venue_token_id, "asks", "0.70", "5")) + stream._handle_decoded_message(depth(binary, binary.yes.venue_token_id, "asks", "0.70", "5")) gate.set() await task assert stream.book(binary.id).best_ask == D("0.70") @@ -160,11 +160,11 @@ async def test_changes_during_the_snapshot_are_replayed(self, binary, catalog): async def test_changes_before_any_snapshot_are_ignored(self, catalog, binary): stream = market_stream(catalog) await stream.watch_order_book([binary.id]) - assert stream.handle(depth(binary, binary.yes.venue_token_id, "bids", "0.5", "1")) == [] + assert stream._handle_decoded_message(depth(binary, binary.yes.venue_token_id, "bids", "0.5", "1")) == [] async def test_a_reconnect_reads_the_books_again(self, catalog, binary): first, second = FakeConnection(), FakeConnection() - stream = market_stream(catalog, connect=Connector(first, second), backoff_initial=0.001) + stream = market_stream(catalog, websocket_connection_opener=Connector(first, second), backoff_initial=0.001) await stream.watch_order_book([binary.id]) stream.start() await wait_for(lambda: stream.book(binary.id).ready) @@ -205,12 +205,12 @@ async def test_an_option_subscribes_by_topic_and_filters_its_siblings(self, cata assert conn.sent == [{"action": "SUBSCRIBE", "channel": "market.last.trade", "rootMarketId": 337}] sibling = {"marketId": 5343, "tokenId": "x", "side": "Buy", "outcomeSide": 1, "price": "0.4", "shares": "1", "amount": "0.4", "msgType": "market.last.trade"} - assert stream.handle(sibling) == [] + assert stream._handle_decoded_message(sibling) == [] async def test_a_no_trade_is_reported_on_the_yes_leg(self, catalog, binary): stream = market_stream(catalog) await stream.watch_trades([binary.id]) - [trade] = stream.handle({"tokenId": binary.no.venue_token_id, "side": "Buy", "outcomeSide": 2, "price": "0.15", + [trade] = stream._handle_decoded_message({"tokenId": binary.no.venue_token_id, "side": "Buy", "outcomeSide": 2, "price": "0.15", "shares": "10", "amount": "1.5", "marketId": 8453, "msgType": "market.last.trade"}) assert isinstance(trade, TradeEvent) assert trade.price == D("0.85") and trade.taker_side == Side.SELL and trade.quantity == D("10") @@ -218,26 +218,26 @@ async def test_a_no_trade_is_reported_on_the_yes_leg(self, catalog, binary): async def test_a_split_is_not_a_trade(self, catalog, binary): stream = market_stream(catalog) await stream.watch_trades([binary.id]) - [event] = stream.handle({"side": "Split", "outcomeSide": 1, "price": "0.5", "shares": "10", + [event] = stream._handle_decoded_message({"side": "Split", "outcomeSide": 1, "price": "0.5", "shares": "10", "marketId": 8453, "msgType": "market.last.trade"}) assert isinstance(event, VenueEvent) and event.name == "split" async def test_last_price_is_a_quote_on_its_side(self, catalog, binary): stream = market_stream(catalog) await stream.watch_ticker([binary.id]) - [quote] = stream.handle({"tokenId": binary.no.venue_token_id, "outcomeSide": 2, "price": "0.15", + [quote] = stream._handle_decoded_message({"tokenId": binary.no.venue_token_id, "outcomeSide": 2, "price": "0.15", "marketId": 8453, "msgType": "market.last.price"}) assert isinstance(quote, QuoteEvent) and quote.side == "no" and quote.last == D("0.15") async def test_venue_errors_are_reported_and_acks_are_not(self, catalog): stream = market_stream(catalog) - assert stream.handle({"code": 400, "message": "invalid channel"}) == [] - assert stream.handle({"code": 0, "message": "ok"}) == [] + assert stream._handle_decoded_message({"code": 400, "message": "invalid channel"}) == [] + assert stream._handle_decoded_message({"code": 0, "message": "ok"}) == [] assert [e.state for e in of(drain(stream), StreamStatusEvent)] == ["error"] async def test_the_heartbeat_frame_is_sent(self, catalog): conn = FakeConnection() - stream = market_stream(catalog, connect=Connector(conn), ping_interval=0.01) + stream = market_stream(catalog, websocket_connection_opener=Connector(conn), ping_interval=0.01) stream.start() await wait_for(lambda: HEARTBEAT in conn.sent or json.loads(HEARTBEAT) in conn.sent) await stream.close() @@ -279,7 +279,7 @@ async def test_orders_and_fills_subscribe_per_topic(self, catalog, option, binar async def test_an_order_update_is_an_order_on_the_yes_leg(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle(ORDER_UPDATE) + [event] = stream._handle_decoded_message(ORDER_UPDATE) assert isinstance(event, OrderEvent) and event.native == "orderConfirm" order = event.order assert order.market_id == "opinion:2770" and order.side == Side.BUY and order.price == D("0.15") @@ -289,13 +289,13 @@ async def test_an_order_update_is_an_order_on_the_yes_leg(self, catalog): async def test_a_no_order_is_a_sell_at_the_complement(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle({**ORDER_UPDATE, "outcomeSide": 2, "status": 3}) + [event] = stream._handle_decoded_message({**ORDER_UPDATE, "outcomeSide": 2, "status": 3}) assert event.order.side == Side.SELL and event.order.price == D("0.85") assert event.order.status == OrderStatus.CANCELED and event.order.remaining == 0 async def test_a_trade_record_is_a_confirmed_fill(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle(TRADE_RECORD) + [event] = stream._handle_decoded_message(TRADE_RECORD) fill = event.fill assert isinstance(event, FillEvent) assert fill.id == TRADE_RECORD["tradeNo"] and fill.order_id == TRADE_RECORD["orderId"] @@ -304,17 +304,17 @@ async def test_a_trade_record_is_a_confirmed_fill(self, catalog): async def test_a_failed_trade_is_reported_failed(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle({**TRADE_RECORD, "status": 6}) + [event] = stream._handle_decoded_message({**TRADE_RECORD, "status": 6}) assert event.fill.settlement == SettlementState.FAILED async def test_a_merge_is_not_a_fill(self, catalog): stream = OpinionUserStream(api_key=KEY, catalog=catalog) - [event] = stream.handle({**TRADE_RECORD, "side": "Merge"}) + [event] = stream._handle_decoded_message({**TRADE_RECORD, "side": "Merge"}) assert isinstance(event, VenueEvent) and event.name == "merge" async def test_a_reconnect_asks_for_reconciliation(self, catalog): first, second = FakeConnection(), FakeConnection() - stream = OpinionUserStream(api_key=KEY, catalog=catalog, connect=Connector(first, second), backoff_initial=0.001) + stream = OpinionUserStream(api_key=KEY, catalog=catalog, websocket_connection_opener=Connector(first, second), backoff_initial=0.001) stream.start() await wait_for(lambda: stream._ws is first) first.feed(ConnectionError("dropped")) diff --git a/tests/test_order_log.py b/tests/test_order_log.py new file mode 100644 index 0000000..c31b70c --- /dev/null +++ b/tests/test_order_log.py @@ -0,0 +1,68 @@ +"""The order log: the journal's order events read back as one log, repeats folded.""" +from __future__ import annotations + +from decimal import Decimal as D +from pathlib import Path + +import pytest + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.engine.order_log import order_log +from synpath.engine.paper import PaperVenue +from synpath.trading.types import Account, OrderRequest + +pytestmark = pytest.mark.anyio +K = "kalshi:KX-LOG" +ACCOUNT = Account(venue="kalshi", name="paper") + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +async def engine(tmp_path: Path): + venue = PaperVenue(venue="kalshi") + venue.set_book(K, bids=[(D("0.40"), D("100"))], asks=[(D("0.45"), D("100"))]) + made = Engine({"kalshi": venue}, EngineConfig(journal_path=str(tmp_path / "log.db"), require_lease=True), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None), + accounts={"kalshi": ACCOUNT}) + await made.start() + yield made + await made.stop() + + +async def test_a_placed_then_cancelled_order_reads_as_its_steps(engine): + order = await engine.submit(OrderRequest(market_id=K, outcome="yes", action="buy", quantity=D("2"), + price=D("0.30"), client_order_id="log-1", account=ACCOUNT)) + await engine.cancel(order.id) + rows, cursor = await order_log(engine, order_id=order.id) + kinds = [row["kind"] for row in reversed(rows)] + assert kinds[:2] == ["submission.reserved", "intent.planned"] and "order.canceled" in kinds and cursor is None + created = next(row for row in rows if row["kind"] == "intent.planned" and row["detail"] == "create") + assert (created["client_order_id"], created["outcome"], created["action"], created["detail"]) == \ + ("log-1", "yes", "buy", "create") + assert any(row["kind"] == "intent.planned" and row["detail"] == "cancel" for row in rows), "the cancel is logged" + + +async def test_the_account_log_pages_newest_first_and_folds_repeats(engine): + for n in range(3): + await engine.submit(OrderRequest(market_id=K, side="buy", quantity=D("1"), price=D("0.3") + D(n) / 100, + client_order_id=f"page-{n}", account=ACCOUNT)) + first, cursor = await order_log(engine, limit=4) + assert [row["seq"] for row in first] == sorted((row["seq"] for row in first), reverse=True) + assert cursor is not None + rest, _ = await order_log(engine, limit=200, before=cursor) + assert all(row["seq"] < first[-1]["seq"] for row in rest) + signatures = [(row["kind"], row["order_id"] or row["client_order_id"], row["status"], row["filled"]) + for row in first + rest] + assert all(a != b for a, b in zip(signatures, signatures[1:])), "a repeat is shown once" + + +def test_a_cancel_s_intent_is_read_as_its_venue_and_order(): + from synpath.engine.order_log import entry + from synpath.engine.storage_types import JournalEvent + for kind, payload in (("intent.sending", {"order_id": None}), ("intent.settled", {"order_id": "o-1"})): + row = entry(JournalEvent(1, 0, kind, "cancel:kalshi:o-1", payload)) + assert (row["venue"], row["order_id"]) == ("kalshi", "o-1") diff --git a/tests/test_order_outcome_action.py b/tests/test_order_outcome_action.py new file mode 100644 index 0000000..c18309f --- /dev/null +++ b/tests/test_order_outcome_action.py @@ -0,0 +1,39 @@ +"""An order says what it does: outcome (YES/NO) and action (buy/sell), beside its YES-leg side.""" +from decimal import Decimal as D + +import pytest + +from synpath.trading.types import Order, OrderRequest, OrderStatus, OrderType, Side, TimeInForce, yes_side + + +def request(**changes): + return OrderRequest(**{"market_id": "polymarket:1", "quantity": D("5"), "price": D("0.40"), **changes}) + + +@pytest.mark.parametrize("outcome,action,side", [ + ("yes", "buy", Side.BUY), ("yes", "sell", Side.SELL), ("no", "buy", Side.SELL), ("no", "sell", Side.BUY)]) +def test_outcome_and_action_set_the_yes_leg_side(outcome, action, side): + made = request(outcome=outcome, action=action) + assert made.side == side == yes_side(outcome, action) and made.leg == (outcome, action) + + +def test_a_side_alone_reads_as_opening_and_a_disagreeing_side_is_refused(): + assert request(side=Side.BUY).leg == ("yes", "buy") + assert request(side=Side.SELL).leg == ("no", "buy") + with pytest.raises(ValueError, match="is not sell yes"): + request(side=Side.BUY, outcome="yes", action="sell") + with pytest.raises(ValueError, match="together"): + request(side=Side.BUY, outcome="yes") + + +def test_reduce_only_is_refused_on_a_new_request_and_read_as_its_meaning_from_storage(): + with pytest.raises(ValueError, match="reduce_only is removed"): + request(side=Side.SELL, reduce_only=True) + stored = {"market_id": "polymarket:1", "quantity": "5", "price": "0.40", "side": "sell", "reduce_only": True} + assert OrderRequest.stored(stored).leg == ("yes", "sell") + assert OrderRequest.stored({**stored, "side": "buy"}).leg == ("no", "sell") + assert OrderRequest.stored({**stored, "reduce_only": False}).leg == ("no", "buy") + order = Order.model_validate({"id": "o", "venue": "polymarket", "market_id": "polymarket:1", "side": "sell", + "type": "limit", "time_in_force": "gtc", "status": "open", "quantity": "5", + "reduce_only": True}) + assert (order.outcome, order.action) == ("yes", "sell") diff --git a/tests/test_order_storage.py b/tests/test_order_storage.py new file mode 100644 index 0000000..bbbedd0 --- /dev/null +++ b/tests/test_order_storage.py @@ -0,0 +1,218 @@ +"""Shared storage behavior: SQLite is the first backend under this contract.""" +from __future__ import annotations + +import json +import sqlite3 +from decimal import Decimal as D + +import pytest + +from synpath.errors import BadRequest +from synpath.engine import Engine, EngineConfig, Journal, RiskConfig +from synpath.engine.paper import PaperVenue +from synpath.engine.storage import JournalStore, create_journal +from synpath.trading.types import Account, Order, OrderRequest + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +async def journal(tmp_path): + async with create_journal(path=str(tmp_path / "journal.db"), user_id="owner-a") as store: + yield store + + +def request(kind="limit", **changes): + fields = dict(market_id="kalshi:KX-A", side="sell", quantity="10", type=kind, + user_id="owner-a", client_order_id="request-a", price="0.38") + if kind.startswith("stop"): + fields["stop_price"] = "0.40" + return OrderRequest(**{**fields, **changes}) + + +@pytest.mark.parametrize("kind", ["market", "limit", "stop_market", "stop_limit"]) +async def test_each_type_has_its_own_table_with_required_owner(journal, kind): + req = request(kind) + account = Account(venue="kalshi", name="paper") + await journal.record_intent(req, client_order_id="request-a", venue="kalshi", account=account) + row = await journal.typed_order(kind, "kalshi", "request-a") + assert row["user_id"] == "owner-a" and row["request"]["quantity"] == "10" + assert row["state"] == "planned" and row["payload_version"] == 1 + with sqlite3.connect(journal.path) as connection: + columns = connection.execute(f"PRAGMA table_info({kind}_orders)").fetchall() + assert next(column for column in columns if column[1] == "user_id")[3] == 1 + + +async def test_instruction_is_persisted_before_send_and_survives_venue_update(journal): + req = request() + account = Account(venue="kalshi", name="paper") + await journal.record_intent(req, client_order_id="request-a", venue="kalshi", account=account) + order = Order(id="venue-a", client_order_id="request-a", user_id="owner-a", venue="kalshi", account=account, + market_id=req.market_id, side=req.side, quantity=D("10"), price=D("0.39"), + type="limit", status="closed", time_in_force="gtc", filled=D("4")) + await journal.upsert_order(order) + row = await journal.typed_order("limit", "kalshi", "request-a") + assert row["request"]["price"] == "0.38" and row["price"] == "0.38" + assert row["snapshot"]["price"] == "0.39" and row["filled"] == "4" + assert row["venue_order_id"] == "venue-a" and row["state"] == "closed" + + +async def test_type_record_and_dispatch_intent_rollback_together(journal, monkeypatch): + original = journal._order_records.write_request + + async def fail_after_type_write(*args, **kwargs): + await original(*args, **kwargs) + raise RuntimeError("interrupted transaction") + + monkeypatch.setattr(journal._order_records, "write_request", fail_after_type_write) + before = await journal.last_seq() + with pytest.raises(RuntimeError, match="interrupted"): + await journal.record_intent(request(), client_order_id="request-a", venue="kalshi", account=Account(venue="kalshi")) + assert await journal.intent("request-a") is None + assert await journal.typed_order("limit", "kalshi", "request-a") is None + assert await journal.last_seq() == before + + +async def test_old_execution_records_keep_unknown_ownership_and_no_invented_request(tmp_path): + path = str(tmp_path / "old.db") + async with Journal(path, user_id="new-owner") as store: + order = Order(id="old", venue="kalshi", market_id="kalshi:KX-A", side="buy", type="limit", + quantity=D("1"), price=None, time_in_force="gtc", status="open") + await store.upsert_order(order) + with sqlite3.connect(path) as connection: + connection.execute("DELETE FROM limit_orders") + connection.execute("DELETE FROM meta WHERE key='order_records_version'") + for _ in range(2): + async with Journal(path, user_id="new-owner") as store: + row = await store.typed_order("limit", "kalshi", "old") + assert row["user_id"] == "legacy:unattributed" and row["request"] is None + assert row["price"] is None + + +async def test_configured_owner_survives_parent_restart_and_is_inherited_by_child(tmp_path, monkeypatch): + monkeypatch.setenv("SYNPATH_USER_ID", "environment-owner") + config = EngineConfig(journal_path=str(tmp_path / "engine.db")) + venue = PaperVenue(venue="kalshi", cash=D("10000")) + risk = RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None) + engine = Engine({"kalshi": venue}, config, risk=risk) + await engine.start() + parent = await engine.submit(request("stop_market", user_id=None, client_order_id=None)) + assert parent.user_id == "environment-owner" and parent.client_order_id + before = await engine.journal.typed_order("stop_market", "kalshi", parent.id) + assert before["state"] == "waiting" and before["user_id"] == "environment-owner" + await engine.stop() + restarted = Engine({"kalshi": venue}, config, risk=risk) + try: + await restarted.start() + restored = restarted.managed_order_controller.get(parent.id) + assert restored.request.user_id == "environment-owner" and restored.state == "waiting" + assert restored.child_request(quantity=D("1"), price=D("0.3")).user_id == "environment-owner" + finally: + await restarted.stop() + + +async def test_storage_contract_rejects_unknown_backend_and_owner_spoofing(tmp_path): + store = create_journal(path=str(tmp_path / "contract.db")) + assert isinstance(store, JournalStore) + with pytest.raises(ValueError, match="not available"): + create_journal(backend="unknown") + with pytest.raises(ValueError, match="empty"): + create_journal(user_id=" ") + venue = PaperVenue(venue="kalshi") + async with Engine({"kalshi": venue}, EngineConfig(journal_path=str(tmp_path / "engine.db"), user_id="owner-a")) as engine: + before = await engine.journal.last_seq() + with pytest.raises(BadRequest, match="assigned"): + await engine.submit(request(user_id="owner-b")) + assert await engine.journal.last_seq() == before + + +async def test_upgrade_restores_unanswered_intent_as_owned_type_instruction(tmp_path): + path = str(tmp_path / "pending.db") + async with Journal(path, user_id="owner-a") as store: + await store.record_intent(request(), client_order_id="request-a", venue="kalshi", account=Account(venue="kalshi")) + await store.mark_intent("request-a", "sending") + with sqlite3.connect(path) as connection: + connection.execute("DELETE FROM limit_orders") + connection.execute("DELETE FROM meta WHERE key='order_records_version'") + async with Journal(path, user_id="owner-a") as store: + row = await store.typed_order("limit", "kalshi", "request-a") + assert row["state"] == "sending" and row["user_id"] == "owner-a" + assert row["request"]["price"] == "0.38" and row["venue_order_id"] is None + + +async def test_failed_migration_closes_the_connection(tmp_path, monkeypatch): + async def fail(self): + raise RuntimeError("migration failed") + + monkeypatch.setattr(Journal, "_upgrade_order_records", fail) + store = Journal(str(tmp_path / "failed.db")) + with pytest.raises(RuntimeError, match="migration failed"): + await store.open() + assert not store.is_open + + +async def test_managed_type_record_preserves_subaccount_identity(journal): + account = Account(venue="kalshi", name="desk", subaccount="17") + snapshot = dict(id="parent", kind="stop_limit", venue="kalshi", state="waiting", + account=account.model_dump(mode="json"), request=request("stop_limit").model_dump(mode="json"), + filled="2", children=[]) + await journal.save_managed(snapshot) + row = await journal.typed_order("stop_limit", "kalshi", "parent") + assert row["account_key"] == "kalshi:desk:17" and row["filled"] == "2" and row["remaining"] == "8" + + +async def test_stop_recovery_uses_type_snapshot_even_if_legacy_projection_is_stale(journal): + account = Account(venue="kalshi") + snapshot = dict(id="parent", kind="stop_market", venue="kalshi", state="working", + account=account.model_dump(mode="json"), request=request("stop_market").model_dump(mode="json"), + filled="0", children=[]) + await journal.save_managed(snapshot) + stale = {**snapshot, "state": "waiting"} + with sqlite3.connect(journal.path) as connection: + connection.execute("UPDATE managed_orders SET payload=?, state='waiting' WHERE id='parent'", (json.dumps(stale),)) + assert (await journal.managed(states=("working",)))[0]["state"] == "working" + assert (await journal.managed_one("parent"))[1]["state"] == "working" + assert (await journal.managed_on("kalshi:KX-A"))[0][1]["state"] == "working" + + +async def test_legacy_stop_snapshot_does_not_inherit_the_new_workers_identity(tmp_path): + path = str(tmp_path / "legacy-stop.db") + snapshot = dict(id="legacy-parent", kind="stop_market", venue="kalshi", state="waiting", + account=Account(venue="kalshi").model_dump(mode="json"), + request=request("stop_market", user_id=None).model_dump(mode="json"), filled="0", children=[]) + async with Journal(path) as store: + await store.save_managed(snapshot) + with pytest.raises(ValueError, match="stored execution owner"): + async with Journal(path, user_id="new-worker"): + pass + with sqlite3.connect(path) as connection: + stored = json.loads(connection.execute("SELECT snapshot FROM stop_market_orders WHERE id='legacy-parent'").fetchone()[0]) + assert stored["request"]["user_id"] is None + + +async def test_canceled_bucket_write_rolls_back_and_allows_next_write(journal, monkeypatch): + import asyncio + entered = asyncio.Event() + original = journal._db.execute + async def pause_member(query, args): + await original(query, args) + entered.set() + await asyncio.Event().wait() + def execute(query, args=None): + if query.startswith("INSERT INTO bucket_members"): + return pause_member(query, args) + return original(query, args) + monkeypatch.setattr(journal._db, "execute", execute) + task = asyncio.create_task(journal.save_bucket({"id": "cancel-bucket", "book": "a", "name": "test", + "members": [{"market_id": "kalshi:KX-A"}]})) + await asyncio.wait_for(entered.wait(), 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert await journal.bucket("cancel-bucket") is None + assert await asyncio.wait_for(journal.append("after-cancellation"), 2) > 0 diff --git a/tests/test_owner_migrations.py b/tests/test_owner_migrations.py new file mode 100644 index 0000000..d229a80 --- /dev/null +++ b/tests/test_owner_migrations.py @@ -0,0 +1,238 @@ +"""Ownership and Alembic upgrade contracts on both real database backends.""" +import importlib +import json +import sqlite3 + +import pytest +from synpath.engine.storage import create_journal +from synpath.engine.database import connect_sqlite, connect_postgres +from synpath.trading.types import Account +from test_order_storage import request +from test_postgres_storage import database + +pytestmark = pytest.mark.anyio + +@pytest.fixture +def anyio_backend(): + return "asyncio" + +@pytest.fixture(params=["sqlite", "postgresql"]) +def configuration(request, tmp_path): + if request.param == "sqlite": + return dict(backend="sqlite", path=str(tmp_path / "journal.db")) + return dict(backend="postgresql") + +@pytest.fixture +def config(configuration, request): + if configuration["backend"] == "postgresql": + dsn, schema = request.getfixturevalue("database") + configuration.update(path=dsn, schema=schema) + return configuration + +async def test_singleton_and_all_instruction_foreign_keys(config): + async with create_journal(**config, user_id="owner-a") as store: + async with store._db.execute("SELECT user_id FROM users") as cursor: + assert [row["user_id"] for row in await cursor.fetchall()] == ["owner-a"] + assert await store.schema_version() == 11 + async with store._db.execute("SELECT version_num FROM alembic_version") as cursor: + assert (await cursor.fetchone())["version_num"] == "0006_routed_execution_scope" + for kind in ("market", "limit", "stop_market", "stop_limit"): + req = request(kind, client_order_id="key-" + kind) + await store.record_intent(req, client_order_id=req.client_order_id, venue="kalshi", account=Account(venue="kalshi")) + with pytest.raises(Exception) as error: + await store._db.execute(f"UPDATE {kind}_orders SET user_id='unknown'") + assert "foreign key" in str(error.value).lower() + with pytest.raises(Exception) as error: + await store._db.execute("INSERT INTO users(user_id) VALUES('second')") + assert "unique" in str(error.value).lower() or "duplicate" in str(error.value).lower() + with pytest.raises(ValueError, match="stored execution owner"): + async with create_journal(**config, user_id="owner-b"): + pass + async with create_journal(**config, user_id="owner-a") as store: + assert (await store.intent("key-stop_market")).state == "planned" + + +async def legacy_connection(config): + if config["backend"] == "sqlite": + connection = await connect_sqlite(config["path"]) + else: + connection = await connect_postgres(config["path"]) + await connection.execute(f'CREATE SCHEMA "{config["schema"]}"') + await connection.execute(f'SET search_path TO "{config["schema"]}", pg_catalog') + source = importlib.import_module("synpath.engine.migrations.versions.0001_journal_v6").SCHEMA + if config["backend"] == "postgresql": + import re + source = re.sub(r"\bINTEGER\b", "BIGINT", source.replace("INTEGER PRIMARY KEY AUTOINCREMENT", "BIGSERIAL PRIMARY KEY")) + await connection.executescript(source) + for key, value in (("schema_version", "6"), ("order_records_version", "1"), ("submissions_version", "1")): + await connection.execute("INSERT INTO meta(key,value) VALUES(?,?)", (key, value)) + return connection + +@pytest.mark.parametrize("owner,has_instruction", [("owner-a", True), ("legacy:unattributed", False), ("other", True)]) +async def test_baseline_adoption_preserves_or_refuses_without_relabeling(config, owner, has_instruction): + connection = await legacy_connection(config) + body = request(user_id=owner).model_dump(mode="json") + await connection.execute("""INSERT INTO limit_orders(venue,id,user_id,account_key,market_id,side,quantity,state,time_in_force,price,request,snapshot,created_ts,updated_ts) + VALUES('kalshi','old',?,'kalshi:default','kalshi:KX-A','sell','10','planned','gtc','0.38',?, ?,1,1)""", + (owner, json.dumps(body) if has_instruction else None, json.dumps(body))) + await connection.close() + if owner == "other": + store = create_journal(**config, user_id="owner-a") + with pytest.raises(ValueError, match="ownership conflicts"): + await store.open() + assert not store.is_open + connection = await (connect_sqlite(config["path"]) if config["backend"] == "sqlite" else connect_postgres(config["path"])) + if config["backend"] == "postgresql": + await connection.execute(f'SET search_path TO "{config["schema"]}", pg_catalog') + async with connection.execute("SELECT user_id FROM limit_orders WHERE id='old'") as cursor: + assert (await cursor.fetchone())["user_id"] == "other" + async with connection.execute("SELECT value FROM meta WHERE key='schema_version'") as cursor: + assert (await cursor.fetchone())["value"] == "6" + await connection.close() + return + for _ in range(2): + async with create_journal(**config, user_id="owner-a") as store: + row = await store.typed_order("limit", "kalshi", "old") + assert row["user_id"] == owner + assert row["request"] == (body if has_instruction else None) + assert await store.schema_version() == 11 + + +async def test_buckets_stored_before_enabled_existed_load_with_every_member_on(config): + connection = await legacy_connection(config) + await connection.execute("INSERT INTO buckets(id,book,name,status,created_ts,updated_ts) VALUES('b','a','old','active',1,1)") + for position, (market_id, flip) in enumerate((("kalshi:KX-A", 0), ("polymarket:1", 1))): + await connection.execute("INSERT INTO bucket_members(bucket_id,market_id,flip,position) VALUES(?,?,?,?)", + ("b", market_id, flip, position)) + await connection.close() + async with create_journal(**config, user_id="owner-a") as store: + assert await store.schema_version() == 11 + members = (await store.bucket("b"))["members"] + assert [(m["market_id"], m["flip"], m["enabled"]) for m in members] == [ + ("kalshi:KX-A", False, True), ("polymarket:1", True, True)] + await store.save_bucket({**(await store.bucket("b")), "members": [ + {"market_id": "kalshi:KX-A"}, {"market_id": "polymarket:1", "flip": True, "enabled": False}]}) + assert [m["enabled"] for m in (await store.bucket("b"))["members"]] == [True, False] + + +async def test_orders_from_before_0004_get_a_creation_time_and_page_newest_first(config): + connection = await legacy_connection(config) + for order_id, created, updated in (("dated", 5, 6), ("undated", None, 9), ("older", 2, 3)): + payload = {"id": order_id, "venue": "kalshi", "market_id": "kalshi:KX-A", "side": "buy", "type": "limit", "time_in_force": "gtc", + "status": "closed", "quantity": "1", "filled": "1"} + await connection.execute( + "INSERT INTO orders(venue,id,account_key,market_id,side,status,terminal,quantity,filled,payload," + "created_ts,updated_ts) VALUES('kalshi',?,'kalshi:default','kalshi:KX-A','buy','closed',1,'1','1',?,?,?)", + (order_id, json.dumps(payload), created, updated)) + await connection.close() + async with create_journal(**config, user_id="owner-a") as store: + assert await store.schema_version() == 11 + rows = await store.orders_page(limit=2) + assert [(order.id, key[0]) for order, key in rows] == [("undated", 9), ("dated", 5)] + rest = await store.orders_page(limit=2, before=rows[-1][1]) + assert [order.id for order, _ in rest] == ["older"] + assert [order.id for order, _ in await store.orders_page(since_ts=5)] == ["undated", "dated"] + if config["backend"] == "sqlite": + query = "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='orders'" + else: + query = f"SELECT indexname AS name FROM pg_indexes WHERE schemaname='{config['schema']}' AND tablename='orders'" + async with store._db.execute(query) as cursor: + names = {row["name"] for row in await cursor.fetchall()} + assert {"orders_recent", "orders_market_recent"} <= names + + +async def test_injected_journal_and_engine_must_share_owner(config): + from synpath.engine import Engine, EngineConfig + async with create_journal(**config, user_id="owner-a") as journal: + connection = journal._db + assert await journal.open() is journal and journal._db is connection + with pytest.raises(ValueError, match="same user_id"): + Engine({}, EngineConfig(user_id="owner-b"), journal=journal) + + +async def test_canceled_migration_rolls_back_then_reopens(config, monkeypatch): + import asyncio + import synpath.engine.migration as migration + original = migration.upgrade + entered = asyncio.Event() + async def pause(database, user_id): + await original(database, user_id) + entered.set() + await asyncio.Event().wait() + monkeypatch.setattr(migration, "upgrade", pause) + store = create_journal(**config, user_id="owner-a") + opening = asyncio.create_task(store.open()) + await asyncio.wait_for(entered.wait(), 5) + opening.cancel() + with pytest.raises(asyncio.CancelledError): + await opening + assert not store.is_open + monkeypatch.setattr(migration, "upgrade", original) + async with create_journal(**config, user_id="owner-a") as restarted: + assert await restarted.schema_version() == 11 + async with restarted._db.execute("SELECT user_id FROM users") as cursor: + assert (await cursor.fetchone())["user_id"] == "owner-a" + + +async def test_cancellation_stays_cancellation_when_driver_operation_fails(): + import asyncio + from synpath.engine.database import finish_database_operation + entered, release = asyncio.Event(), asyncio.Event() + async def fails(): + entered.set() + await release.wait() + raise RuntimeError("driver failed while caller was canceled") + task = asyncio.create_task(finish_database_operation(fails())) + await entered.wait() + task.cancel() + await asyncio.sleep(0) + release.set() + with pytest.raises(asyncio.CancelledError): + await task + + +async def test_repeated_cancellation_keeps_begin_rollback_locked(): + import asyncio + connection = await connect_sqlite(":memory:") + begin_entered, begin_release = asyncio.Event(), asyncio.Event() + rollback_entered, rollback_release = asyncio.Event(), asyncio.Event() + driver = connection.connection + class DelayedConnection: + def __getattr__(self, name): + return getattr(driver, name) + async def exec_driver_sql(self, query, *args): + if query.startswith("BEGIN"): + begin_entered.set() + await begin_release.wait() + elif query == "ROLLBACK": + rollback_entered.set() + await rollback_release.wait() + return await driver.exec_driver_sql(query, *args) + connection.connection = DelayedConnection() + await connection.execute("CREATE TABLE observed(value INTEGER)") + async def begin(): + await connection.execute("BEGIN IMMEDIATE") + opening = asyncio.create_task(begin()) + write = None + try: + await begin_entered.wait() + opening.cancel() + begin_release.set() + await rollback_entered.wait() + opening.cancel() + async def insert(): + await connection.execute("INSERT INTO observed VALUES(1)") + write = asyncio.create_task(insert()) + await asyncio.sleep(0.01) + assert connection.lock.locked() and not write.done() + rollback_release.set() + with pytest.raises(asyncio.CancelledError): + await opening + await write + async with connection.execute("SELECT value FROM observed") as cursor: + assert (await cursor.fetchone())["value"] == 1 + finally: + begin_release.set() + rollback_release.set() + await asyncio.gather(opening, *([write] if write else []), return_exceptions=True) + await connection.close() diff --git a/tests/test_polymarket_rfq.py b/tests/test_polymarket_rfq.py new file mode 100644 index 0000000..34099e8 --- /dev/null +++ b/tests/test_polymarket_rfq.py @@ -0,0 +1,1008 @@ +"""Polymarket (global) combo RFQs, offline (P7 of docs/rfq-combo.md). + +Responses are the shapes in the venue's docs (/trading/combos/requesters, +/trading/combos/market-makers, /api-spec/combos-rfq-openapi.yaml), saved as +`tests/samples/polymarket_rfq_*.json` with test ids and addresses. The docs' +example deadline (March 2026) would read as expired today, so the samples +carry 2100-01-01 and the expiry tests move it. Nothing was captured live. + +Covered: combos (provisional and venue ids, leg resolution), every +requester and quoter method, side and price mapping, the worst-price +refusal before signing, the EOA refusal, error mapping, the synthetic RFQ +orders and fills, the quoter WebSocket, and one engine end-to-end run +through `engine.rfq` with mocked HTTP. +""" +from __future__ import annotations + +import asyncio +import copy +import json +from decimal import Decimal +from pathlib import Path + +import httpx +import pytest +import respx +from eth_account import Account as EthAccount +from eth_account.messages import encode_typed_data + +from synpath.errors import AuthenticationError, ExchangeNotAvailable, NotSupported, RateLimitExceeded +from synpath.trading import polymarket_rfq as prfq +from synpath.trading import polymarket_v3_signing as v3 +from synpath.trading.credentials import PolymarketCredentials +from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderNotFound, OrderRejected, RfqNotFound +from synpath.trading.limiter import BudgetLimiter +from synpath.trading.polymarket import PolymarketTrading +from synpath.trading.types import ( + AcceptQuote, ComboLeg, MakerQuote, OrderStatus, OrderType, QuoteStatus, RfqCloseReason, RfqStatus, Side, +) + +D = Decimal +pytestmark = pytest.mark.anyio + +SAMPLES = Path(__file__).parent / "samples" +KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +WALLET = "0x1111111111111111111111111111111111111111" +SECRET = "YSB0ZXN0IHNlY3JldCB0aGF0IGlzIG5vdCByZWFsISE=" +REQ = prfq.REQUESTER_URL + prfq.REQUESTS_PATH +API = prfq.RFQ_API_URL +GAMMA = "https://gamma-api.polymarket.com" +COND = "0x03" + "ab" * 31 +COMBO_ID = f"polymarket:combo-{COND}" +LEGS = ["101", "202"] +PROVISIONAL = "polymarket:combo-legs-65.ca" +BUY_ID = "rfq_0f1e2d3c4b5a69788796a5b4c3d2e1f0" +BUY_QUOTE = "quote_aaaabbbbccccddddeeeeffff00001111" + + +def sample(name: str) -> dict: + return json.loads((SAMPLES / f"polymarket_rfq_{name}.json").read_text()) + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +def mock(): + with respx.mock(assert_all_called=False) as router: + yield router + + +def make(signature_type: int = 3, **kw) -> PolymarketTrading: + creds = PolymarketCredentials( + private_key=KEY, signature_type=signature_type, funder=None if signature_type == 0 else WALLET, + api_key="api-key-uuid", api_secret=SECRET, api_passphrase="pass", **kw) + poly = PolymarketTrading(creds, client=httpx.AsyncClient(), + limiter=BudgetLimiter(read_per_second=1000, write_per_second=1000)) + poly.remember_combo_market(sample("combo_markets")["markets"][0]) + poly.remember_combo_market(sample("combo_markets")["markets"][1]) + return poly + + +@pytest.fixture +async def poly(): + adapter = make() + yield adapter + await adapter.close() + + +def legs() -> list[ComboLeg]: + return [ComboLeg(market_id="polymarket:1897034", outcome="yes"), + ComboLeg(market_id="polymarket:1897035", outcome="no")] + + +def body_of(route, n: int = -1) -> dict: + return json.loads(route.calls[n].request.content) + + +def check_l2(request: httpx.Request) -> None: + import base64 + import hashlib + import hmac + + stamp = request.headers["POLY_TIMESTAMP"] + body = request.content.decode() if request.content else "" + expected = base64.urlsafe_b64encode(hmac.new( + base64.urlsafe_b64decode(SECRET), f"{stamp}{request.method}{request.url.path}{body}".encode(), + hashlib.sha256).digest()).decode() + assert request.headers["POLY_SIGNATURE"] == expected + assert request.headers["POLY_ADDRESS"] == ADDRESS + assert request.headers["POLY_API_KEY"] == "api-key-uuid" and request.headers["POLY_PASSPHRASE"] == "pass" + + +async def created_buy(poly, mock, response: dict | None = None): + route = mock.post(REQ).mock(return_value=httpx.Response(200, json=response or sample("create_buy"))) + rfq = await poly.create_rfq(PROVISIONAL, amount=D("1"), outcome="yes", action="buy") + return rfq, route + + +async def created_sell(poly, mock): + mock.post(REQ).mock(return_value=httpx.Response(200, json=sample("create_sell"))) + return await poly.create_rfq(PROVISIONAL, quantity=D("2"), outcome="yes", action="sell") + + +def accept(quote_id: str = BUY_QUOTE, rfq_id: str = BUY_ID, **kw) -> AcceptQuote: + base = dict(quote_id=quote_id, rfq_id=rfq_id, outcome="yes", action="buy") + return AcceptQuote(**{**base, **kw}) + + +# -- capability keys --------------------------------------------------------------- + + +def test_capability_keys(): + for key in ("rfq_taker", "rfq_quoter", "combos"): + assert PolymarketTrading.has[key] is True + assert PolymarketTrading.has["rfq"] is False + + +# -- combos ------------------------------------------------------------------------ + + +class TestCombos: + async def test_create_combo_is_offline_and_provisional(self, poly, mock): + combo = await poly.create_combo(legs()) + assert combo.market_id == PROVISIONAL and combo.info["provisional"] is True + assert combo.info["leg_position_ids"] == LEGS + assert [(l.market_id, l.outcome, l.info["position_id"]) for l in combo.legs] == [ + ("polymarket:1897034", "yes", "101"), ("polymarket:1897035", "no", "202")] + assert not mock.calls, "nothing is sent: the venue has no combo create" + assert prfq.legs_of_provisional(combo.market_id) == LEGS + + async def test_after_an_rfq_the_combo_has_its_venue_id(self, poly, mock): + rfq, _ = await created_buy(poly, mock) + assert rfq.combo_id == COMBO_ID + assert rfq.combo.info["yes_position_id"] == "789" and rfq.combo.info["no_position_id"] == "790" + assert (await poly.create_combo(legs())).market_id == COMBO_ID + assert (await poly.fetch_combo(COMBO_ID)).market_id == COMBO_ID + assert (await poly.fetch_combo(PROVISIONAL)).market_id == COMBO_ID + + async def test_legs_checked(self, poly): + with pytest.raises(InvalidOrder, match="2 to 50 legs"): + await poly.create_combo(legs()[:1]) + with pytest.raises(InvalidOrder, match="each market once"): + await poly.create_combo([legs()[0], legs()[0].model_copy(update={"outcome": "no"})]) + with pytest.raises(InvalidOrder, match="each leg once"): + prfq.sorted_legs(["1", "01"]) + + async def test_leg_from_its_given_position_id(self, poly): + combo = await poly.create_combo([ + ComboLeg(market_id="polymarket:9", outcome="yes", info={"position_id": "5"}), + ComboLeg(market_id="polymarket:8", outcome="no", info={"position_id": "3"})]) + assert combo.info["leg_position_ids"] == ["3", "5"] + + async def test_leg_from_gamma_position_ids(self, poly, mock): + route = mock.get(f"{GAMMA}/markets/77").mock(return_value=httpx.Response( + 200, json={"id": "77", "positionIds": ["771", "772"]})) + combo = await poly.create_combo([ComboLeg(market_id="polymarket:77", outcome="no"), legs()[0]]) + assert route.called and combo.info["leg_position_ids"] == ["101", "772"] + + async def test_unknown_leg(self, poly, mock): + mock.get(f"{GAMMA}/markets/66").mock(return_value=httpx.Response(200, json={"id": "66"})) + with pytest.raises(InvalidOrder, match="no combo position id known"): + await poly.create_combo([ComboLeg(market_id="polymarket:66", outcome="yes"), legs()[0]]) + + async def test_catalogue(self, mock): + poly = make() + poly._positions.clear() + poly._position_of.clear() + route = mock.get(f"{API}/v1/rfq/combo-markets").mock(return_value=httpx.Response(200, json=sample("combo_markets"))) + page = await poly.fetch_combo_markets(limit=50, exclude=["0x1", "0x2"]) + assert len(page) == 2 and page.next_cursor == "Mg" + assert dict(route.calls[0].request.url.params) == {"limit": "50", "exclude": "0x1,0x2"} + assert "POLY_SIGNATURE" not in route.calls[0].request.headers, "the catalogue is public" + assert poly._positions["102"] == ("polymarket:1897034", "no") + await poly.close() + + async def test_eligible_markets_from_a_live_catalogue_page(self, mock): + """A trimmed real page (unauthenticated GET, 2026-10-22).""" + poly = make() + live = json.loads((Path(__file__).parent / "samples" / "polymarket_rfq_combo_markets_live.json").read_text()) + route = mock.get(f"{API}/v1/rfq/combo-markets").mock(return_value=httpx.Response(200, json=live)) + page = await poly.fetch_combo_eligible_markets(limit=2, cursor="OQ") + assert dict(route.calls[0].request.url.params) == {"limit": "2", "cursor": "OQ"} + first = page[0] + assert first.market_id == "polymarket:665374" and first.venue == "polymarket" + assert first.title == "Will the U.S. invade Iran before 2027?" and first.event_id is None + pids = live["markets"][0]["position_ids"] + assert first.position_ids == {"yes": pids[0], "no": pids[1]} + assert (first.min_legs, first.max_legs) == (prfq.MIN_LEGS, prfq.MAX_LEGS) and first.status == "open" + assert first.tags == ["politics", "iran", "trump"] and first.info["slug"] == "will-the-us-invade-iran-before-2027" + assert page.next_cursor == "OQ" + await poly.close() + + async def test_eligible_markets_tag_filter_and_no_event_filter(self, mock): + from synpath.errors import BadRequest + + poly = make() + route = mock.get(f"{API}/v1/rfq/combo-markets").mock(return_value=httpx.Response(200, json=sample("combo_markets"))) + page = await poly.fetch_combo_eligible_markets(tag="Soccer") + assert [m.market_id for m in page] == ["polymarket:1897034"] and page.next_cursor == "Mg" + assert "tag" not in dict(route.calls[0].request.url.params), "the catalogue takes no filter" + with pytest.raises(BadRequest, match="does not name events"): + await poly.fetch_combo_eligible_markets(event_id="polymarket:123") + await poly.close() + + async def test_a_collection_is_refused(self, poly): + with pytest.raises(InvalidOrder, match="not built in collections"): + await poly.create_combo(legs(), collection_id="KXMVECROSSCATEGORY-R") + + async def test_fetch_unknown_combo_and_no_lookup(self, poly): + with pytest.raises(RfqNotFound): + await poly.fetch_combo(COMBO_ID) + with pytest.raises(NotSupported, match="no combo lookup"): + await poly.lookup_combo(legs()) + + async def test_approvals(self, poly, monkeypatch): + seen = {} + + async def calls(calls, *, description, **kw): + seen.update(calls=calls, description=description) + return {"ok": True} + + monkeypatch.setattr(poly, "execute_wallet_calls", calls) + await poly.approve_combo_trading() + assert seen["calls"] == v3.combo_approval_calls() + + +# -- requester --------------------------------------------------------------------- + + +class TestCreate: + async def test_buy_by_amount(self, poly, mock): + rfq, route = await created_buy(poly, mock) + sent = body_of(route) + assert sent == { + "signer_address": WALLET, "maker_address": WALLET, "signature_type": 3, "leg_position_ids": LEGS, + "direction": "BUY", "side": "YES", "requested_size": {"unit": "notional", "value_e6": "1000000"}, + } + check_l2(route.calls[0].request) + assert rfq.id == BUY_ID and rfq.status == RfqStatus.OPEN and rfq.mine + assert (rfq.outcome, rfq.action, rfq.amount, rfq.quantity) == ("yes", "buy", D("1"), None) + assert rfq.expires_at == 4102444800000 and rfq.info["response"]["quote"]["quote_id"] == BUY_QUOTE + + async def test_sell_by_quantity(self, poly, mock): + rfq = await created_sell(poly, mock) + assert (rfq.action, rfq.quantity, rfq.amount) == ("sell", D("2"), None) + assert body_of(mock.routes[0])["requested_size"] == {"unit": "shares", "value_e6": "2000000"} + + async def test_proxy_and_safe_identity(self, mock): + for kind in (1, 2): + poly = make(signature_type=kind) + route = mock.post(REQ).mock(return_value=httpx.Response(200, json=sample("create_buy"))) + await poly.create_rfq(PROVISIONAL, amount=D("1"), action="buy") + sent = body_of(route) + assert (sent["signer_address"], sent["maker_address"], sent["signature_type"]) == (ADDRESS, WALLET, kind) + await poly.close() + + async def test_eoa_is_refused_before_sending(self, mock): + poly = make(signature_type=0) + with pytest.raises(InvalidOrder, match="refuse EOA wallets"): + await poly.create_rfq(PROVISIONAL, amount=D("1"), action="buy") + assert not mock.calls + await poly.close() + + @pytest.mark.parametrize("kw, match", [ + (dict(amount=D("1"), action="buy", outcome="no"), "YES only"), + (dict(amount=D("1")), "one side"), + (dict(quantity=D("1"), action="buy"), "sized by amount"), + (dict(amount=D("1"), action="sell"), "sized by quantity"), + (dict(amount=D("1.0000001"), action="buy"), "six decimals"), + (dict(amount=D("1"), action="buy", rest_remainder=True), "remainder"), + ]) + async def test_refusals_send_nothing(self, poly, mock, kw, match): + with pytest.raises(InvalidOrder, match=match): + await poly.create_rfq(PROVISIONAL, **kw) + assert not mock.calls + + async def test_a_single_market_is_refused(self, poly, mock): + with pytest.raises(InvalidOrder, match="combos only"): + await poly.create_rfq("polymarket:1897034", amount=D("1"), action="buy") + with pytest.raises(InvalidOrder, match="not been seen"): + await poly.create_rfq(COMBO_ID, amount=D("1"), action="buy") + + async def test_by_combo_legs(self, poly, mock): + route = mock.post(REQ).mock(return_value=httpx.Response(200, json=sample("create_buy"))) + combo = await poly.create_combo(legs()) + rfq = await poly.create_rfq("polymarket:anything", amount=D("1"), action="buy", combo=combo) + assert body_of(route)["leg_position_ids"] == LEGS and rfq.combo_id == COMBO_ID + + async def test_an_open_rfq_is_not_made_twice(self, poly, mock): + first, route = await created_buy(poly, mock) + again = await poly.create_rfq(PROVISIONAL, amount=D("1"), action="buy") + assert again.id == first.id and route.call_count == 1 + + async def test_no_quotes_is_a_closed_rfq(self, poly, mock): + rfq, _ = await created_buy(poly, mock, sample("create_no_quotes")) + assert rfq.status == RfqStatus.CLOSED and rfq.closed_reason is None + assert rfq.info["error"]["code"] == "NO_QUOTES" and rfq.combo_id == PROVISIONAL + with pytest.raises(RfqNotFound, match="without a trade"): + await poly.fetch_quotes(rfq.id) + + async def test_unknown_legs_resolved_through_gamma(self, mock): + poly = make() + poly._positions.clear() + poly._position_of.clear() + keyset = mock.get(f"{GAMMA}/markets/keyset").mock(return_value=httpx.Response( + 200, json={"markets": sample("combo_markets")["markets"]})) + mock.post(REQ).mock(return_value=httpx.Response(200, json=sample("create_buy"))) + rfq = await poly.create_rfq(PROVISIONAL, amount=D("1"), action="buy") + assert keyset.called and keyset.calls[0].request.url.params.get_list("position_ids") == LEGS + assert [l.market_id for l in rfq.combo.legs] == ["polymarket:1897034", "polymarket:1897035"] + await poly.close() + + +class TestReads: + async def test_quote_maps_a_buy_to_the_ask(self, poly, mock): + await created_buy(poly, mock) + [quote] = await poly.fetch_quotes(BUY_ID) + assert (quote.id, quote.rfq_id, quote.market_id) == (BUY_QUOTE, BUY_ID, COMBO_ID) + assert (quote.ask, quote.bid, quote.ask_quantity) == (D("0.5"), None, D("1.932381")) + assert quote.status == QuoteStatus.OPEN and not quote.mine + assert quote.info["total_required"] == "1" and quote.price_for("yes", "buy") == D("0.5") + assert quote.price_for("yes", "sell") is None + + async def test_quote_maps_a_sell_to_the_bid(self, poly, mock): + await created_sell(poly, mock) + [quote] = await poly.fetch_quotes("rfq_sell_1") + assert (quote.bid, quote.ask, quote.bid_quantity) == (D("0.45"), None, D("2")) + + async def test_no_status_read_before_the_accept(self, poly, mock): + await created_buy(poly, mock) + get = mock.get(f"{REQ}/{BUY_ID}") + assert (await poly.fetch_rfq(BUY_ID)).status == RfqStatus.OPEN + assert (await poly.fetch_quote(BUY_QUOTE)).id == BUY_QUOTE + assert not get.called, "the gateway answers 409 before an accept" + + async def test_fetch_rfqs_lists_this_process(self, poly, mock): + await created_buy(poly, mock) + await created_sell(poly, mock) + assert {r.id for r in await poly.fetch_rfqs()} == {BUY_ID, "rfq_sell_1"} + assert len(await poly.fetch_rfqs(status="closed")) == 0 + assert len(await poly.fetch_rfqs(market_id=COMBO_ID)) == 2 + assert len(await poly.fetch_rfqs(market_id=PROVISIONAL)) == 2 + assert len(await poly.fetch_rfqs(limit=1)) == 1 + + async def test_unknown_rfq(self, poly): + with pytest.raises(RfqNotFound, match="not created by this adapter"): + await poly.fetch_rfq("rfq_x") + with pytest.raises(RfqNotFound): + await poly.fetch_quote("quote_x") + + async def test_expired_window(self, poly, mock): + response = sample("create_buy") + response["expires_at"] = 1_000 + await created_buy(poly, mock, response) + quote = await poly.fetch_quote(BUY_QUOTE, rfq_id=BUY_ID) + assert quote.status == QuoteStatus.EXPIRED + rfq = await poly.fetch_rfq(BUY_ID) + assert rfq.status == RfqStatus.CLOSED and rfq.closed_reason == RfqCloseReason.EXPIRED + with pytest.raises(OrderRejected, match="not open"): + await poly.accept_quote(accept()) + + async def test_cancel_is_local(self, poly, mock): + await created_buy(poly, mock) + await poly.cancel_rfq(BUY_ID) + assert len(mock.calls) == 1, "the gateway has no requester cancel" + rfq = await poly.fetch_rfq(BUY_ID) + assert rfq.status == RfqStatus.CLOSED and rfq.closed_reason == RfqCloseReason.CANCELED + with pytest.raises(OrderRejected): + await poly.accept_quote(accept()) + with pytest.raises(RfqNotFound): + await poly.cancel_rfq("rfq_x") + + async def test_remember_rfq_after_a_restart(self, poly, mock): + rfq, _ = await created_buy(poly, mock) + fresh = make() + fresh.remember_rfq(rfq) + assert (await fresh.fetch_quote(BUY_QUOTE)).ask == D("0.5") + await fresh.close() + + +class TestAccept: + async def test_signs_an_exchange_v3_order(self, poly, mock): + await created_buy(poly, mock) + route = mock.post(f"{REQ}/{BUY_ID}/accept").mock(return_value=httpx.Response(200, json=sample("accept_executing"))) + result = await poly.accept_quote(accept(worst_price=D("0.5"))) + sent = body_of(route) + order = sent["signed_order"] + assert sent["quote_id"] == BUY_QUOTE + assert {k: order[k] for k in ("maker", "signer", "tokenId", "makerAmount", "takerAmount", "side", + "signatureType", "metadata", "builder")} == { + "maker": WALLET, "signer": WALLET, "tokenId": "789", "makerAmount": "966191", "takerAmount": "1932381", + "side": 0, "signatureType": 3, "metadata": v3.ZERO_BYTES32, "builder": v3.ZERO_BYTES32} + assert len(order["timestamp"]) == 10, "Unix seconds" + check_l2(route.calls[0].request) + # The inner signature recovers to the signer over the docs' Deposit Wallet payload. + unsigned = {k: v for k, v in order.items() if k != "signature"} + inner = bytes.fromhex(order["signature"][2:])[:65] + assert EthAccount.recover_message(encode_typed_data(full_message=v3.deposit_wallet_typed_data(unsigned)), + signature=inner) == ADDRESS + assert result.status == QuoteStatus.CONFIRMED and result.accepted_side == Side.BUY + assert result.info["order_hash"] == v3.order_hash(unsigned) + assert result.info["accept"]["client_order_id"] == f"rfq-accept:polymarket:{BUY_QUOTE}" + + async def test_a_sell(self, poly, mock): + await created_sell(poly, mock) + route = mock.post(f"{REQ}/rfq_sell_1/accept").mock(return_value=httpx.Response( + 200, json={"rfq_id": "rfq_sell_1", "status": "AWAITING_MAKER_CONFIRMATION"})) + result = await poly.accept_quote(accept("quote_sell_1", "rfq_sell_1", action="sell", worst_price=D("0.45"))) + order = body_of(route)["signed_order"] + assert (order["side"], order["makerAmount"], order["takerAmount"]) == (1, "2000000", "900000") + assert result.status == QuoteStatus.ACCEPTED and result.accepted_side == Side.SELL + + async def test_proxy_signs_the_order_directly(self, mock): + poly = make(signature_type=1) + await created_buy(poly, mock) + route = mock.post(f"{REQ}/{BUY_ID}/accept").mock(return_value=httpx.Response(200, json=sample("accept_executing"))) + await poly.accept_quote(accept()) + order = body_of(route)["signed_order"] + assert (order["maker"], order["signer"], order["signatureType"]) == (WALLET, ADDRESS, 1) + unsigned = {k: v for k, v in order.items() if k != "signature"} + assert EthAccount.recover_message(encode_typed_data(full_message=v3.order_typed_data(unsigned)), + signature=order["signature"]) == ADDRESS + await poly.close() + + @pytest.mark.parametrize("worst", [D("0.49"), D("0.4999")]) + async def test_worst_price_refuses_before_signing(self, poly, mock, worst): + await created_buy(poly, mock) + route = mock.post(f"{REQ}/{BUY_ID}/accept") + with pytest.raises(InvalidOrder, match="worse than the worst price"): + await poly.accept_quote(accept(worst_price=worst)) + assert not route.called + + async def test_worst_price_on_a_sell(self, poly, mock): + await created_sell(poly, mock) + with pytest.raises(InvalidOrder, match="worse than the worst price"): + await poly.accept_quote(accept("quote_sell_1", "rfq_sell_1", action="sell", worst_price=D("0.46"))) + + @pytest.mark.parametrize("kw, error, match", [ + (dict(action="sell"), InvalidOrder, "asked to buy"), + (dict(outcome="no", action="sell"), InvalidOrder, "outcome='yes'"), + (dict(quantity=D("1")), InvalidOrder, "full size"), + ]) + async def test_side_and_size_refusals(self, poly, mock, kw, error, match): + await created_buy(poly, mock) + with pytest.raises(error, match=match): + await poly.accept_quote(accept(**kw)) + assert len(mock.calls) == 1 + + async def test_eoa_accept_refused(self, mock): + poly = make(signature_type=0) + with pytest.raises(InvalidOrder, match="UNSUPPORTED_REQUESTER_SIGNATURE_TYPE"): + await poly.accept_quote(accept()) + await poly.close() + + async def test_retry_does_not_resend(self, poly, mock): + await created_buy(poly, mock) + route = mock.post(f"{REQ}/{BUY_ID}/accept").mock(return_value=httpx.Response(200, json=sample("accept_executing"))) + mock.get(f"{REQ}/{BUY_ID}").mock(return_value=httpx.Response(200, json=sample("status_mined"))) + await poly.accept_quote(accept()) + again = await poly.accept_quote(accept()) + assert route.call_count == 1 and again.info["accept"]["resent"] is False + assert again.status == QuoteStatus.CONFIRMED + + async def test_maker_declined_reads_as_canceled(self, poly, mock): + await created_buy(poly, mock) + mock.post(f"{REQ}/{BUY_ID}/accept").mock(return_value=httpx.Response(200, json=sample("accept_declined"))) + result = await poly.accept_quote(accept()) + assert result.status == QuoteStatus.CANCELED and result.info["error"]["code"] == "MAKER_DECLINED" + + async def test_follow_to_a_fill(self, poly, mock): + await created_buy(poly, mock) + mock.post(f"{REQ}/{BUY_ID}/accept").mock(return_value=httpx.Response(200, json=sample("accept_executing"))) + status = mock.get(f"{REQ}/{BUY_ID}").mock(side_effect=[ + httpx.Response(200, json=sample("status_mined")), httpx.Response(200, json=sample("status_filled"))]) + await poly.accept_quote(accept()) + assert (await poly.fetch_quote(BUY_QUOTE, rfq_id=BUY_ID)).status == QuoteStatus.CONFIRMED + quote = await poly.fetch_quote(BUY_QUOTE, rfq_id=BUY_ID) + assert quote.status == QuoteStatus.EXECUTED and quote.taker_order_id == f"rfq:{BUY_ID}" + check_l2(status.calls[0].request) + order = await poly.fetch_order(quote.taker_order_id) + assert (order.status, order.type, order.side, order.market_id) == ( + OrderStatus.CLOSED, OrderType.RFQ_TAKER, Side.BUY, COMBO_ID) + assert (order.filled, order.price, order.fee, order.outcome, order.action) == ( + D("1.932381"), D("0.5"), D("0.033809"), "yes", "buy") + [fill] = await poly.fetch_my_trades(market_id=COMBO_ID) + assert (fill.order_id, fill.quantity, fill.price, fill.info["tx_hash"]) == (order.id, D("1.932381"), D("0.5"), "0xtx1") + assert len(await poly.fetch_my_trades(order_id=order.id)) == 1 + assert status.call_count == 2, "a final status is not read again" + assert (await poly.fetch_rfq(BUY_ID)).closed_reason == RfqCloseReason.EXECUTED + + async def test_status_409_keeps_what_is_known(self, poly, mock): + await created_buy(poly, mock) + mock.post(f"{REQ}/{BUY_ID}/accept").mock(return_value=httpx.Response(200, json=sample("accept_executing"))) + mock.get(f"{REQ}/{BUY_ID}").mock(return_value=httpx.Response(409, json={"error": "bad state", "code": "INVALID_RFQ_STATE"})) + await poly.accept_quote(accept()) + assert (await poly.fetch_quote(BUY_QUOTE)).status == QuoteStatus.CONFIRMED + + async def test_unaccepted_rfq_has_no_order(self, poly, mock): + await created_buy(poly, mock) + with pytest.raises(OrderNotFound, match="never accepted"): + await poly.fetch_order(f"rfq:{BUY_ID}") + with pytest.raises(OrderNotFound): + await poly.fetch_order("rfq:unknown") + + +class TestErrors: + @pytest.mark.parametrize("status, body, error, match", [ + (400, {"error": "unsupported", "code": "UNSUPPORTED_REQUESTER_SIGNATURE_TYPE"}, InvalidOrder, "EOA"), + (400, {"error": "invalid rfq", "code": "INVALID_RFQ"}, InvalidOrder, "INVALID_RFQ"), + (400, {"error": "builder", "code": "BUILDER_ATTRIBUTION_NOT_ALLOWED"}, InvalidOrder, "BUILDER"), + (401, {"error": "unauthenticated", "code": "UNAUTHENTICATED"}, AuthenticationError, "UNAUTHENTICATED"), + (404, {"error": "unknown rfq", "code": "UNKNOWN_RFQ"}, RfqNotFound, "unknown rfq"), + (409, {"error": "expired", "code": "EXPIRED_RFQ"}, OrderRejected, "EXPIRED_RFQ"), + (503, {"error": "reserve", "code": "PRE_EXECUTION_BALANCE_RESERVATION_FAILED"}, InsufficientFunds, "reserve"), + (503, {"error": "down", "code": "SERVICE_UNAVAILABLE"}, ExchangeNotAvailable, "SERVICE_UNAVAILABLE"), + (503, {"error": "submit", "code": "TRADE_SUBMISSION_FAILED"}, ExchangeNotAvailable, "TRADE_SUBMISSION_FAILED"), + ]) + async def test_accept_errors(self, poly, mock, status, body, error, match): + await created_buy(poly, mock) + mock.post(f"{REQ}/{BUY_ID}/accept").mock(return_value=httpx.Response(status, json=body)) + with pytest.raises(error, match=match) as caught: + await poly.accept_quote(accept()) + if isinstance(caught.value, OrderRejected): + assert caught.value.reason == "expired_rfq" + + async def test_rate_limited(self, poly, mock, monkeypatch): + async def instant(_): + return None + + monkeypatch.setattr(asyncio, "sleep", instant) + mock.post(REQ).mock(return_value=httpx.Response(429, json={"error": "rate limited", "code": "RATE_LIMITED"})) + with pytest.raises(RateLimitExceeded): + await poly.create_rfq(PROVISIONAL, amount=D("1"), action="buy") + + +# -- quoter ------------------------------------------------------------------------ + + +async def session(poly, *names: str): + """A quoter session with these messages delivered (no socket).""" + poly.rfq_session = prfq.PolymarketRfqSession(poly) + for name in names: + await poly.rfq_session.handle(sample(name)) + return poly.rfq_session + + +class TestQuoter: + async def test_no_session_no_incoming(self, poly): + assert list(await poly.fetch_incoming_rfqs()) == [] + + async def test_incoming_from_the_session(self, poly): + await session(poly, "ws_request_buy", "ws_request_sell") + rows = {r.id: r for r in await poly.fetch_incoming_rfqs()} + buy, sell = rows["rfq_in_1"], rows["rfq_in_2"] + assert (buy.action, buy.amount, buy.combo_id, buy.mine) == ("buy", D("1"), COMBO_ID, False) + assert (sell.action, sell.quantity) == ("sell", D("1")) + assert [l.market_id for l in buy.combo.legs] == ["polymarket:1897034", "polymarket:1897035"] + assert len(await poly.fetch_incoming_rfqs(market_id="polymarket:combo-0xother")) == 0 + + async def test_quote_a_buy_request(self, poly, mock): + await session(poly, "ws_request_buy") + route = mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + quote = await poly.send_quote(MakerQuote(rfq_id="rfq_in_1", ask=D("0.45"), client_order_id="k1", + expires_at=4102444800000)) + sent = body_of(route) + check_l2(route.calls[0].request) + assert sent["quote_id"] == prfq.quote_id_for("k1") and sent["quote_id"].startswith("quote_") + assert len(sent["quote_id"]) == len("quote_") + 32 + assert (sent["price_e6"], sent["size_e6"], sent["valid_until"]) == ("450000", "2222222", 4102444800000) + order = sent["signed_order"] + # Fill a BUY of YES from collateral: buy NO at 1 - 0.45, ceil. + assert (order["tokenId"], order["side"], order["makerAmount"], order["takerAmount"]) == ("790", 0, "1222223", "2222222") + assert (sent["signer_address"], sent["maker_address"], sent["signature_type"]) == (WALLET, WALLET, 3) + assert (quote.ask, quote.bid, quote.ask_quantity, quote.mine, quote.status) == ( + D("0.45"), None, D("2.222222"), True, QuoteStatus.OPEN) + assert (await poly.fetch_quote(quote.id)).id == quote.id + + async def test_quote_a_sell_request_partly(self, poly, mock): + await session(poly, "ws_request_sell") + route = mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + quote = await poly.send_quote(MakerQuote(rfq_id="rfq_in_2", bid=D("0.45"), ask=D("0.6"), quantity=D("0.5"))) + order = body_of(route)["signed_order"] + assert (order["tokenId"], order["makerAmount"], order["takerAmount"]) == ("789", "225000", "500000") + assert (quote.bid, quote.ask, quote.bid_quantity) == (D("0.45"), None, D("0.5")) + + async def test_eoa_quoter_signs_as_itself(self, mock): + poly = make(signature_type=0) + await session(poly, "ws_request_sell") + route = mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + await poly.send_quote(MakerQuote(rfq_id="rfq_in_2", bid=D("0.45"))) + sent = body_of(route) + assert (sent["signer_address"], sent["maker_address"], sent["signature_type"]) == (ADDRESS, ADDRESS, 0) + order = sent["signed_order"] + unsigned = {k: v for k, v in order.items() if k != "signature"} + assert EthAccount.recover_message(encode_typed_data(full_message=v3.order_typed_data(unsigned)), + signature=order["signature"]) == ADDRESS + await poly.close() + + @pytest.mark.parametrize("quote, match", [ + (MakerQuote(rfq_id="rfq_in_1", bid=D("0.4")), "quote the ask"), + (MakerQuote(rfq_id="rfq_in_1", ask=D("0.95")), "0.90909"), + (MakerQuote(rfq_id="rfq_in_1", ask=D("0.45"), quantity=D("5")), "more than the request"), + (MakerQuote(rfq_id="rfq_in_1", ask=D("0.45"), post_only=True), "post_only"), + (MakerQuote(rfq_id="rfq_x", ask=D("0.45")), "not one the quoter session received"), + ]) + async def test_quote_refusals(self, poly, mock, quote, match): + await session(poly, "ws_request_buy") + with pytest.raises(InvalidOrder, match=match): + await poly.send_quote(quote) + assert not mock.calls + + async def test_cancel_then_replace(self, poly, mock): + await session(poly, "ws_request_buy") + mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + cancel = mock.post(f"{API}/v1/maker/quotes/cancel").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + first = await poly.send_quote(MakerQuote(rfq_id="rfq_in_1", ask=D("0.45"), client_order_id="a")) + second = await poly.replace_quote(first.id, MakerQuote(rfq_id="rfq_in_1", ask=D("0.44"), client_order_id="b")) + assert body_of(cancel) == {"rfq_id": "rfq_in_1", "quote_id": first.id, "signer_address": WALLET, + "maker_address": WALLET, "signature_type": 3} + assert second.id != first.id and second.info["replaces"] == first.id + assert (await poly.fetch_quote(first.id)).status == QuoteStatus.CANCELED + with pytest.raises(InvalidOrder, match="give rfq_id"): + await poly.cancel_quote("quote_unknown") + + async def test_last_look_confirm_and_execution(self, poly, mock): + await session(poly, "ws_request_buy") + mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + confirm = mock.post(f"{API}/v1/maker/confirmations").mock(return_value=httpx.Response(200, json=sample("maker_execution"))) + quote = await poly.send_quote(MakerQuote(rfq_id="rfq_in_1", ask=D("0.45"))) + await poly.rfq_session.handle({"type": "RFQ_CONFIRMATION_REQUEST", "rfq_id": "rfq_in_1", "quote_id": quote.id, + "direction": "BUY", "side": "YES", "fill_size_e6": "2000000", + "price_e6": "450000", "confirm_by": 4102444800000}) + accepted = await poly.fetch_quote(quote.id) + assert accepted.status == QuoteStatus.ACCEPTED and accepted.accepted_side == Side.BUY + assert accepted.confirm_by == 4102444800000 and accepted.ask_quantity == D("2") + confirmed = await poly.confirm_quote(accepted) + assert body_of(confirm)["decision"] == "CONFIRM" and confirmed.status == QuoteStatus.CONFIRMED + await poly.rfq_session.handle({"type": "RFQ_EXECUTION_UPDATE", "rfq_id": "rfq_in_1", "status": "CONFIRMED", + "tx_hash": "0xtx2"}) + done = await poly.fetch_quote(quote.id) + assert done.status == QuoteStatus.EXECUTED and done.maker_order_id == f"rfq:rfq_in_1:{quote.id}" + order = await poly.fetch_order(done.maker_order_id) + assert (order.type, order.side, order.filled, order.price, order.outcome, order.action) == ( + OrderType.RFQ_MAKER, Side.SELL, D("2"), D("0.45"), "no", "buy") + [fill] = await poly.fetch_my_trades(order_id=order.id) + assert fill.liquidity.value == "maker" and fill.info["tx_hash"] == "0xtx2" + assert [q.id for q in await poly.fetch_my_quotes(status="executed")] == [quote.id] + + async def test_decline_during_the_last_look(self, poly, mock): + await session(poly, "ws_request_buy") + mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + confirm = mock.post(f"{API}/v1/maker/confirmations").mock(return_value=httpx.Response( + 200, json={"snapshot": sample("maker_snapshot")})) + quote = await poly.send_quote(MakerQuote(rfq_id="rfq_in_1", ask=D("0.45"))) + await poly.rfq_session.handle({"type": "RFQ_CONFIRMATION_REQUEST", "rfq_id": "rfq_in_1", "quote_id": quote.id}) + await poly.cancel_quote(quote.id) + assert body_of(confirm)["decision"] == "DECLINE" + assert (await poly.fetch_quote(quote.id)).status == QuoteStatus.CANCELED + + async def test_lost_failed_and_stale_quotes(self, poly, mock, monkeypatch): + await session(poly, "ws_request_buy") + mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + lost = await poly.send_quote(MakerQuote(rfq_id="rfq_in_1", ask=D("0.45"))) + await poly.rfq_session.handle({"type": "RFQ_TRADE", "rfq_id": "rfq_in_1", "price_e6": "440000"}) + assert (await poly.fetch_quote(lost.id)).status == QuoteStatus.CANCELED + await poly.rfq_session.handle(sample("ws_request_sell")) + failed = await poly.send_quote(MakerQuote(rfq_id="rfq_in_2", bid=D("0.45"))) + await poly.rfq_session.handle({"type": "RFQ_ERROR", "request_type": "RFQ_QUOTE", "rfq_id": "rfq_in_2", + "quote_id": failed.id, "code": "SUBMISSION_WINDOW_CLOSED", "error": "late"}) + assert (await poly.fetch_quote(failed.id)).status == QuoteStatus.FAILED + stale = await poly.send_quote(MakerQuote(rfq_id="rfq_in_2", bid=D("0.44"), client_order_id="s")) + poly._maker[stale.id].sent_at -= 60_000 + assert (await poly.fetch_quote(stale.id)).status == QuoteStatus.EXPIRED + + async def test_maker_error_mapping(self, poly, mock): + await session(poly, "ws_request_buy") + mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response( + 400, json={"error": "allowance", "code": "ALLOWANCE_VALIDATION_FAILED"})) + with pytest.raises(InsufficientFunds, match="approve_combo_trading"): + await poly.send_quote(MakerQuote(rfq_id="rfq_in_1", ask=D("0.45"))) + + async def test_the_socket_authenticates_first(self, poly): + sent: list[dict] = [] + incoming = [json.dumps({"type": "auth", "success": True, "address": ADDRESS, "role": "maker"}), + json.dumps(sample("ws_request_buy"))] + + class Socket: + async def send(self, text): + sent.append(json.loads(text)) + + def __aiter__(self): + return self + + async def __anext__(self): + if not incoming: + raise StopAsyncIteration + return incoming.pop(0) + + async def close(self): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + urls = [] + seen = [] + + def connect(url): + urls.append(url) + return Socket() + + live = await poly.start_rfq_session(connect=connect, on_rfq=lambda rfq: seen.append(rfq.id)) + await asyncio.wait_for(live._task, 1) + assert urls == [prfq.QUOTER_WS_URL] + assert sent[0] == {"type": "auth", "auth": {"apiKey": "api-key-uuid", "secret": SECRET, "passphrase": "pass"}, + "identity": {"signer_address": WALLET, "maker_address": WALLET, "signature_type": 3}} + assert live.authenticated is True and live.role == "maker" and seen == ["rfq_in_1"] + await poly.stop_rfq_session() + assert poly.rfq_session is None + + +# -- the engine, end to end -------------------------------------------------------- + + +async def test_engine_accept_to_fill_to_position(tmp_path, mock): + from synpath.engine import Engine, EngineConfig, RiskConfig + from synpath.engine.storage_types import IntentState + + poly = make() + engine = Engine({"polymarket": poly}, + EngineConfig(journal_path=str(tmp_path / "engine.db"), in_doubt_timeout_s=0, + rfq_poll_interval_s=0.01, rfq_incoming_interval_s=None), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None)) + await engine.start() + try: + mock.post(REQ).mock(return_value=httpx.Response(200, json=sample("create_buy"))) + accept_route = mock.post(f"{REQ}/{BUY_ID}/accept").mock( + return_value=httpx.Response(200, json=sample("accept_executing"))) + mock.get(f"{REQ}/{BUY_ID}").mock(return_value=httpx.Response(200, json=sample("status_filled"))) + combo = await engine.create_combo(legs(), venue="polymarket") + rfq = await engine.create_rfq(combo.market_id, amount=D("1"), outcome="yes", action="buy", book="alpha") + assert rfq.combo_id == COMBO_ID and f"polymarket:{rfq.id}" in engine.rfq.rfqs + await engine.rfq.tick() + quote = (await poly.fetch_quotes(rfq.id))[0] + + result = await engine.accept_quote(AcceptQuote(quote_id=quote.id, rfq_id=rfq.id, outcome="yes", action="buy", + worst_price=D("0.51"), book="alpha")) + assert result.status == QuoteStatus.CONFIRMED and accept_route.call_count == 1 + await engine.rfq.tick() + + [intent] = [i for i in await engine.journal.intents() if i.operation == "accept_quote"] + assert intent.state == IntentState.SENT and intent.order_id == f"rfq:{BUY_ID}" + order = await engine.journal.order("polymarket", f"rfq:{BUY_ID}") + assert order is not None and order.type == OrderType.RFQ_TAKER and order.book == "alpha" + [position] = [p for p in engine.positions() if p.market_id == COMBO_ID] + assert position.contracts == D("1.932381") + assert f"polymarket:{rfq.id}" not in engine.rfq.rfqs, "the RFQ closed as executed" + finally: + await engine.journal.close() + await poly.close() + + +# -- approvals by role --------------------------------------------------------------- + + +class TestApprovals: + PUSD = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" + PM = "0x006F54F7f9A22e0000CC2AB60031000000ae9fEF" + MAX = "f" * 64 + + @staticmethod + def word(address: str) -> str: + return "0" * 24 + address[2:].lower() + + def approve(self, spender: str) -> str: + return "0x095ea7b3" + self.word(spender) + self.MAX # approve(address,uint256) + + def for_all(self, operator: str) -> str: + return "0xa22cb465" + self.word(operator) + "0" * 63 + "1" # setApprovalForAll(address,bool) + + def test_default_is_exchange_v3_only(self): + calls = v3.combo_approval_calls() + assert calls == [ + {"target": self.PUSD, "value": "0", "data": self.approve(v3.EXCHANGE_V3)}, + {"target": self.PM, "value": "0", "data": self.for_all(v3.EXCHANGE_V3)}, + ] + + def test_router_and_auto_redeemer(self): + router, redeemer = "0x12121212006e4CD160D18e3f00711DA5c3372600", "0xa1200000d0002264C9a1698e001292D00E1b00af" + assert (v3.ROUTER, v3.AUTO_REDEEMER) == (router, redeemer) + calls = v3.combo_approval_calls(include_router=True, include_auto_redeem=True) + assert [(c["target"], c["data"]) for c in calls] == [ + (self.PUSD, self.approve(v3.EXCHANGE_V3)), (self.PM, self.for_all(v3.EXCHANGE_V3)), + (self.PUSD, self.approve(router)), (self.PM, self.for_all(router)), + (self.PM, self.for_all(redeemer)), + ] + assert len(v3.combo_approval_calls(include_router=True)) == 4 + assert [c["data"] for c in v3.combo_approval_calls(include_auto_redeem=True)][-1] == self.for_all(redeemer) + + async def test_the_adapter_passes_the_flags(self, poly, monkeypatch): + seen = {} + + async def calls(calls, *, description, **kw): + seen["calls"] = calls + return {"ok": True} + + monkeypatch.setattr(poly, "execute_wallet_calls", calls) + await poly.approve_combo_trading(include_router=True) + assert seen["calls"] == v3.combo_approval_calls(include_router=True) + + +# -- the engine: pushed quote requests and restarts ------------------------------------ + + +def engine_on(tmp_path, poly, **kw): + from synpath.engine import Engine, EngineConfig, RiskConfig + + base = dict(journal_path=str(tmp_path / "engine.db"), in_doubt_timeout_s=0, rfq_poll_interval_s=0.01, + rfq_incoming_interval_s=None) + return Engine({"polymarket": poly}, EngineConfig(**{**base, **kw}), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None)) + + +async def test_engine_quotes_a_pushed_request_inside_the_window(tmp_path, mock): + """Gap 1 on the real adapter: the quoter session's RFQ_REQUEST reaches an + engine callback, which quotes it, with no poll in between.""" + import time + + poly = make() + engine = engine_on(tmp_path, poly, rfq_incoming_interval_s=2.0, rfq_stream_incoming=False) + await engine.start() + try: + assert engine.rfq.pushed == {"polymarket"} + route = mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + stamps: dict[str, float] = {} + + async def quoter(rfq): + stamps["callback"] = time.perf_counter() + await engine.send_quote(MakerQuote(rfq_id=rfq.id, ask=D("0.45"), book="mm"), venue="polymarket") + stamps["quoted"] = time.perf_counter() + + engine.rfq.on_incoming_rfq(quoter) + poly.rfq_session = prfq.PolymarketRfqSession(poly) # a fake stream: no socket + start = time.perf_counter() + await poly.rfq_session.handle(sample("ws_request_buy")) + await asyncio.gather(*list(poly._listener_tasks)) + to_callback, to_quote = (stamps["callback"] - start) * 1000, (stamps["quoted"] - start) * 1000 + print(f"polymarket RFQ_REQUEST -> callback {to_callback:.3f} ms, -> quote posted {to_quote:.3f} ms") + assert to_callback < 50 and to_quote < 400, "inside the 400 ms window, no poll delay" + assert route.call_count == 1 and "polymarket:rfq_in_1" in engine.rfq.incoming + finally: + await engine.journal.close() + await poly.close() + + +async def accepted_then_crashed(tmp_path, mock, *, accept_fails: bool = False): + poly = make() + engine = engine_on(tmp_path, poly) + await engine.start() + mock.post(REQ).mock(return_value=httpx.Response(200, json=sample("create_buy"))) + if accept_fails: + mock.post(f"{REQ}/{BUY_ID}/accept").mock(side_effect=httpx.ConnectError("reset")) + else: + mock.post(f"{REQ}/{BUY_ID}/accept").mock(return_value=httpx.Response(200, json=sample("accept_executing"))) + combo = await engine.create_combo(legs(), venue="polymarket") + rfq = await engine.create_rfq(combo.market_id, amount=D("1"), outcome="yes", action="buy", book="alpha") + quote = (await poly.fetch_quotes(rfq.id))[0] + try: + await engine.accept_quote(AcceptQuote(quote_id=quote.id, rfq_id=rfq.id, outcome="yes", action="buy", + worst_price=D("0.51"), book="alpha")) + except Exception: + if not accept_fails: + raise + await engine.journal.close() # the process dies; the adapter's memory with it + await poly.close() + mock.reset() + return rfq + + +async def test_restart_follows_an_accept_to_a_position(tmp_path, mock): + """Gap 2: the gateway lists no RFQs, so the engine re-seeds the adapter + from its journal and the in-flight accept resolves after the restart.""" + from synpath.engine.storage_types import IntentState + + rfq = await accepted_then_crashed(tmp_path, mock) + fresh = make() + status = mock.get(f"{REQ}/{BUY_ID}").mock(return_value=httpx.Response(200, json=sample("status_filled"))) + engine = engine_on(tmp_path, fresh) + recovery = await engine.start() + try: + assert status.called, "the re-seeded RFQ is read as accepted" + assert recovery.adopted == 1, recovery.details + [intent] = [i for i in await engine.journal.intents() if i.operation == "accept_quote"] + assert intent.state == IntentState.SENT and intent.order_id == f"rfq:{BUY_ID}" + [position] = [p for p in engine.positions() if p.market_id == COMBO_ID] + assert position.contracts == D("1.932381") + assert f"polymarket:{rfq.id}" not in engine.rfq.rfqs + finally: + await engine.journal.close() + await fresh.close() + + +async def test_restart_with_an_accept_that_never_landed_sends_nothing(tmp_path, mock): + from synpath.engine.storage_types import IntentState + + await accepted_then_crashed(tmp_path, mock, accept_fails=True) + fresh = make() + mock.get(f"{REQ}/{BUY_ID}").mock(return_value=httpx.Response( + 409, json={"error": "not accepted", "code": "INVALID_RFQ_STATE"})) + accept = mock.post(f"{REQ}/{BUY_ID}/accept") + engine = engine_on(tmp_path, fresh, lost_after_s=0) + await engine.start() + try: + [intent] = [i for i in await engine.journal.intents() if i.operation == "accept_quote"] + assert intent.state == IntentState.REJECTED, "the quote reads open: the accept never reached the venue" + assert not accept.called and engine.positions() == [] + finally: + await engine.journal.close() + await fresh.close() + + +async def test_an_open_rfq_can_be_cancelled_after_a_restart(tmp_path, mock): + poly = make() + engine = engine_on(tmp_path, poly) + await engine.start() + mock.post(REQ).mock(return_value=httpx.Response(200, json=sample("create_buy"))) + rfq = await engine.create_rfq(PROVISIONAL, amount=D("1"), outcome="yes", action="buy", book="alpha") + await engine.journal.close() + await poly.close() + + fresh = make() + engine = engine_on(tmp_path, fresh) + await engine.start() + try: + assert [r.id for r in await fresh.fetch_rfqs(status="open")] == [rfq.id], "re-seeded from the working set" + await engine.cancel_rfq(rfq.id, venue="polymarket") + assert (await fresh.fetch_rfq(rfq.id)).closed_reason == RfqCloseReason.CANCELED + assert engine.rfq.rfqs == {} + finally: + await engine.journal.close() + await fresh.close() + + +async def test_restart_follows_a_confirm_in_doubt(tmp_path, mock): + """Our quote's confirm was sent, then the process died: the quote is + re-seeded `confirmed` (not unknown, which would read as nothing traded) + and the execution the session reports later is adopted.""" + from synpath.engine.storage_types import IntentState + + poly = make() + engine = engine_on(tmp_path, poly) + await engine.start() + mock.post(f"{API}/v1/maker/quotes").mock(return_value=httpx.Response(200, json=sample("maker_snapshot"))) + mock.post(f"{API}/v1/maker/confirmations").mock(return_value=httpx.Response( + 200, json={"snapshot": sample("maker_snapshot")})) + await session(poly, "ws_request_buy") + await engine.rfq.watch_incoming() + quote = await engine.send_quote(MakerQuote(rfq_id="rfq_in_1", ask=D("0.45"), book="mm"), venue="polymarket") + await poly.rfq_session.handle({"type": "RFQ_CONFIRMATION_REQUEST", "rfq_id": "rfq_in_1", "quote_id": quote.id, + "fill_size_e6": "2000000", "confirm_by": 4102444800000}) + await engine.confirm_quote(quote.id, venue="polymarket") + await engine.journal.close() + await poly.close() + + fresh = make() + engine = engine_on(tmp_path, fresh, lost_after_s=0) + await engine.start() + try: + [intent] = [i for i in await engine.journal.intents() if i.operation == "confirm_quote"] + assert intent.state == IntentState.SENDING, "still in doubt, not failed" + assert (await fresh.fetch_quote(quote.id)).status == QuoteStatus.CONFIRMED + fresh.rfq_session = prfq.PolymarketRfqSession(fresh) + await fresh.rfq_session.handle({"type": "RFQ_EXECUTION_UPDATE", "rfq_id": "rfq_in_1", "status": "CONFIRMED", + "tx_hash": "0xtx9"}) + await engine.rfq.tick() + intent = await engine.journal.intent(intent.client_order_id) + assert intent.state == IntentState.SENT and intent.order_id == f"rfq:rfq_in_1:{quote.id}" + [position] = [p for p in engine.positions() if p.market_id == COMBO_ID] + assert position.contracts == D("2") + finally: + await engine.journal.close() + await fresh.close() diff --git a/tests/test_polymarket_stop_lifecycle.py b/tests/test_polymarket_stop_lifecycle.py new file mode 100644 index 0000000..191681a --- /dev/null +++ b/tests/test_polymarket_stop_lifecycle.py @@ -0,0 +1,137 @@ +"""Synthetic stops through the real Polymarket adapter over mocked HTTP. + +Only public test signing keys are used. No wallet or live venue is contacted. +""" +import json +from decimal import Decimal as D +from types import SimpleNamespace + +import httpx +import pytest + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.trading.types import OrderRequest, OrderStatus +from test_polymarket_trading import venue, adapter, MARKET, TOKEN, NO_TOKEN, DOC_ORDER + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +def engine_for(poly, path): + return Engine({"polymarket": poly}, EngineConfig(journal_path=str(path), user_id="mock-poly-owner"), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None)) + + +@pytest.mark.parametrize("side,leg,token_id,wire_side,maker,taker", [ + ("buy", None, TOKEN, "BUY", "2400000", "5000000"), # Buy YES + ("buy", ("no", "sell"), NO_TOKEN, "SELL", "5000000", "2600000"), # Sell NO held + ("sell", None, NO_TOKEN, "BUY", "3100000", "5000000"), # Buy NO + ("sell", ("yes", "sell"), TOKEN, "SELL", "5000000", "1900000"), # Sell YES held +]) +async def test_waiting_restart_limit_child_direction_and_targeted_cancel(venue, tmp_path, side, leg, + token_id, wire_side, maker, taker): + venue.on("POST", "/order", body={"success": True, "orderID": "0xchild", "status": "live"}) + venue.on("DELETE", "/order", body={"canceled": ["0xchild"], "not_canceled": {}}) + venue.on("GET", "/data/order/0xchild", body={**DOC_ORDER, "id": "0xchild", "asset_id": token_id, + "side": wire_side, "original_size": "5", "status": "CANCELED"}) + poly = adapter(venue) + path = tmp_path / "orders.db" + engine = engine_for(poly, path) + request = OrderRequest(market_id=MARKET, side=side, quantity="5", type="stop_limit", + stop_price="0.45" if side == "buy" else "0.40", + price="0.48" if side == "buy" else "0.38", post_only=True, + **({"outcome": leg[0], "action": leg[1]} if leg else {}), client_order_id="stop-a") + try: + await engine.start() + engine.set_book(MARKET, SimpleNamespace(ready=True, best_bid=D("0.44"), best_ask=D("0.44"))) + parent = await engine.submit(request) + await engine.on_book({"market_id": MARKET}) + assert parent.status == OrderStatus.WAITING and venue.sent("POST", "/order") == [] + await engine.stop() + engine = engine_for(poly, path) + await engine.start() + assert (await engine.submit(request)).id == parent.id + engine.set_book(MARKET, SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.46"))) + await engine.on_book({"market_id": MARKET}) + assert engine.managed_order_controller.get(parent.id).as_order().status == OrderStatus.TRIGGERED + sent = venue.sent("POST", "/order") + assert len(sent) == 1 + body = json.loads(sent[0].content) + assert body["orderType"] == "GTC" and body["postOnly"] is True + assert body["order"]["tokenId"] == token_id and body["order"]["side"] == wire_side + assert body["order"]["makerAmount"] == maker and body["order"]["takerAmount"] == taker + assert (await engine.cancel(parent.id)).status == OrderStatus.CANCELED + assert len(venue.sent("DELETE", "/order")) == 1 + assert venue.sent("DELETE", "/cancel-all") == [] + await engine.stop() + engine = engine_for(poly, path) + await engine.start() + replay = await engine.submit(request) + assert replay.id == parent.id and replay.status == OrderStatus.CANCELED + assert len(venue.sent("POST", "/order")) == 1 + finally: + await engine.stop() + await poly.close() + + +@pytest.mark.parametrize("side,token_id,wire_price", [("buy", TOKEN, "0.99"), ("sell", NO_TOKEN, "0.99")]) +async def test_stop_market_child_uses_fak_and_remains_pending_until_fill_readback(venue, tmp_path, side, token_id, wire_price): + venue.on("POST", "/order", body={"success": True, "orderID": "0xchild", "status": "matched"}) + venue.on("GET", "/data/order/0xchild", body={**DOC_ORDER, "id": "0xchild", "asset_id": token_id, + "side": "BUY", "original_size": "5", "size_matched": "5", + "price": wire_price, "order_type": "FAK", "status": "MATCHED"}) + poly = adapter(venue) + engine = engine_for(poly, tmp_path / "orders.db") + try: + await engine.start() + parent = await engine.submit(OrderRequest(market_id=MARKET, side=side, quantity="5", type="stop_market", + stop_price="0.40", client_order_id="market-a")) + engine.set_book(MARKET, SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.41"))) + await engine.on_book({"market_id": MARKET}) + managed = engine.managed_order_controller.get(parent.id) + assert managed.as_order().status == OrderStatus.TRIGGERED and managed.filled == 0 + sent = json.loads(venue.sent("POST", "/order")[0].content) + assert sent["orderType"] == "FAK" and sent["order"]["tokenId"] == token_id + assert sent["order"]["makerAmount"] == "4950000" and sent["order"]["takerAmount"] == "5000000" + await engine.on_order(await poly.fetch_order("0xchild")) + assert managed.as_order().status == OrderStatus.CLOSED and managed.filled == 5 and managed.remaining == 0 + finally: + await engine.stop() + await poly.close() + + +async def test_response_lost_never_resends_but_needs_signed_identity_recovery(venue, tmp_path): + poly = adapter(venue) + original = venue.handler + def lose_response(request): + if request.method == "POST" and request.url.path == "/order": + venue.requests.append(request) + raise httpx.ReadError("accepted; response lost", request=request) + return original(request) + poly.clob._client = httpx.AsyncClient(transport=httpx.MockTransport(lose_response)) + venue.on("GET", "/data/orders", body={"data": [{**DOC_ORDER, "id": "0xunlinked", "original_size": "5"}], "next_cursor": "LTE="}) + path = tmp_path / "orders.db" + engine = engine_for(poly, path) + req = OrderRequest(market_id=MARKET, side="buy", quantity="5", type="stop_limit", + stop_price="0.40", price="0.42", client_order_id="uncertain-a") + try: + await engine.start() + parent = await engine.submit(req) + engine.set_book(MARKET, SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.41"))) + await engine.on_book({"market_id": MARKET}) + assert engine.managed_order_controller.get(parent.id).pending_child is not None + await engine.stop() + engine = engine_for(poly, path) + recovery = await engine.start() + assert recovery.unresolved == 1 + restored = engine.managed_order_controller.get(parent.id) + assert restored.pending_child is not None and restored.children == [] + assert (await engine.submit(req)).id == parent.id + assert len(venue.sent("POST", "/order")) == 1 + finally: + await engine.stop() + await poly.close() diff --git a/tests/test_polymarket_trading.py b/tests/test_polymarket_trading.py index 5e36d84..2261af6 100644 --- a/tests/test_polymarket_trading.py +++ b/tests/test_polymarket_trading.py @@ -300,17 +300,18 @@ def test_serialization_is_compact_and_stable(self): class TestLegs: - """One rule for the four intents: buy takes YES, sell takes NO, and - reduce_only turns either into selling the tokens held.""" - - @pytest.mark.parametrize("side,reduce_only,outcome,venue_side,wire_price", [ - (Side.BUY, False, "yes", "BUY", "0.52"), - (Side.SELL, False, "no", "BUY", "0.48"), - (Side.SELL, True, "yes", "SELL", "0.52"), - (Side.BUY, True, "no", "SELL", "0.48"), + """The four intents, each its own token and venue side: the order's + outcome and action, sent as they are (YES-leg side and price beside).""" + + @pytest.mark.parametrize("side,leg,outcome,venue_side,wire_price", [ + (Side.BUY, ("yes", "buy"), "yes", "BUY", "0.52"), + (Side.SELL, ("no", "buy"), "no", "BUY", "0.48"), + (Side.SELL, ("yes", "sell"), "yes", "SELL", "0.52"), + (Side.BUY, ("no", "sell"), "no", "SELL", "0.48"), + (Side.SELL, None, "no", "BUY", "0.48"), # neither given: a sell buys NO ]) - def test_wire_leg_and_price(self, side, reduce_only, outcome, venue_side, wire_price): - request = req(side=side, reduce_only=reduce_only) + def test_wire_leg_and_price(self, side, leg, outcome, venue_side, wire_price): + request = req(side=side, **({"outcome": leg[0], "action": leg[1]} if leg else {})) assert wire_leg(request) == (outcome, venue_side) info = token(token_id=TOKENS.token(outcome)) entry, _ = build(request, info=info) diff --git a/tests/test_polymarket_us_rfq.py b/tests/test_polymarket_us_rfq.py new file mode 100644 index 0000000..b83c680 --- /dev/null +++ b/tests/test_polymarket_us_rfq.py @@ -0,0 +1,675 @@ +"""Polymarket US combos and RFQ on the retail API, offline (P2 of docs/rfq-combo.md). + +Nothing here was captured live. Request and response shapes follow the +venue's published docs: the OpenAPI schemas +`api-reference/oapi-schemas/{combos,rfqs}-schema.json`, the examples in +`api-reference/combos/overview.md`, `api-reference/rfqs/overview.md` and +`trader-guide/combos.md`, and `partners/orders/combos-takers.md`. Samples are +in `tests/samples/polymarket_us_rfq_*.json`. +""" +from __future__ import annotations + +import base64 +import json +from decimal import Decimal +from pathlib import Path + +import httpx +import pytest +import respx +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 + +from synpath.errors import AuthenticationError, NotSupported, RateLimitExceeded +from synpath.trading import ( + AcceptQuote, Combo, ComboLeg, MakerQuote, QuoteStatus, Rfq, RfqQuote, RfqStatus, Side, +) +from synpath.trading import polymarket_us as retail +from synpath.trading.base import TradingExchange +from synpath.trading.credentials import PolymarketUSCredentials +from synpath.trading.errors import InsufficientFunds, InvalidOrder, OrderRejected, RfqNotFound +from synpath.trading.limiter import BudgetLimiter + +D = Decimal +pytestmark = pytest.mark.anyio +API = retail.API_URL +SAMPLES = Path(__file__).parent / "samples" +ATL = "tec-mlb-nlchamp-2026-09-27-atl" +NYY = "tec-mlb-alchamp-2026-09-27-nyy" +COMBO = "caoc-example" + + +def sample(name: str): + return json.loads((SAMPLES / f"polymarket_us_rfq_{name}.json").read_text()) + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(scope="module") +def ed_key(): + return ed25519.Ed25519PrivateKey.generate() + + +@pytest.fixture +def venue(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + mock.get("/v1/rfqs/user-id").respond(json=sample("user_id")) + yield mock + + +@pytest.fixture +def us(venue, ed_key): + raw = ed_key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, serialization.NoEncryption()) + return retail.PolymarketUSTrading( + PolymarketUSCredentials(key_id="key-1", secret_key=base64.b64encode(raw).decode()), + client=httpx.AsyncClient(), limiter=BudgetLimiter(read_per_second=1000, write_per_second=1000), + rfq_pace_per_second=None, # TestPacing covers the per-endpoint pace on a fake clock + ) + + +def body(route) -> dict: + return json.loads(route.calls.last.request.content) + + +def query(route) -> dict: + return dict(route.calls.last.request.url.params) + + +def accept(**kw) -> AcceptQuote: + base = dict(quote_id="quote_01", rfq_id="rfq_01", outcome="yes", action="buy") + base.update(kw) + return AcceptQuote(**base) + + +# -- interface --------------------------------------------------------------------- + + +class TestInterface: + NAMES = ( + "create_combo", "fetch_combo", "lookup_combo", "fetch_combo_eligible_markets", + "create_rfq", "fetch_rfq", "fetch_rfqs", "cancel_rfq", "fetch_quotes", "fetch_quote", "accept_quote", + "fetch_incoming_rfqs", "send_quote", "replace_quote", "cancel_quote", "confirm_quote", "fetch_my_quotes", + ) + + def test_typed_methods_live_on_the_base(self): + for name in self.NAMES: + assert name in TradingExchange.__dict__, name + + async def test_base_defaults_are_not_supported(self): + class Bare(TradingExchange): + id = "bare" + + async def create_order(self, request): ... + async def cancel_order(self, order_id, *, market_id=None): ... + async def fetch_order(self, order_id): ... + async def fetch_open_orders(self, *, market_id=None): ... + async def fetch_balance(self, *, account=None): ... + async def fetch_positions(self, *, market_id=None, event_id=None): ... + + with pytest.raises(NotSupported, match="bare: accept_quote"): + await Bare().accept_quote(accept()) + with pytest.raises(NotSupported, match="bare: create_combo"): + await Bare().create_combo([]) + with pytest.raises(NotSupported, match="bare: fetch_combo_eligible_markets"): + await Bare().fetch_combo_eligible_markets() + + def test_capability_keys(self): + has = retail.PolymarketUSTrading.has + assert has["rfq_taker"] is True and has["rfq_quoter"] is True and has["combos"] is True + assert has["rfq"] is False # the old key names the raw Kalshi methods + + +# -- combos ------------------------------------------------------------------------ + + +class TestCombos: + async def test_create_maps_outcomes_to_leg_sides(self, us, venue, ed_key): + route = venue.post("/v1/combos").respond(json=sample("create_combo")) + combo = await us.create_combo([ + ComboLeg(market_id=f"polymarket_us:{ATL}", outcome="yes"), ComboLeg(market_id=NYY, outcome="no"), + ]) + assert body(route) == {"legs": [{"symbol": ATL, "side": "SIDE_BUY"}, {"symbol": NYY, "side": "SIDE_SELL"}]} + sent = route.calls.last.request + ed_key.public_key().verify( + base64.b64decode(sent.headers["X-PM-Signature"]), f"{sent.headers['X-PM-Timestamp']}POST/v1/combos".encode(), + ) + assert isinstance(combo, Combo) and combo.market_id == f"polymarket_us:{COMBO}" and combo.venue == "polymarket_us" + assert [(l.market_id, l.outcome) for l in combo.legs] == [(f"polymarket_us:{ATL}", "yes"), (f"polymarket_us:{NYY}", "no")] + assert combo.status == "open" and combo.created_at == 1785333600000 and combo.info["tickSize"] == 0.001 + + async def test_create_is_idempotent(self, us, venue): + route = venue.post("/v1/combos").respond(json=sample("create_combo")) + legs = [ComboLeg(market_id=ATL, outcome="yes"), ComboLeg(market_id=NYY, outcome="no")] + first, second = await us.create_combo(legs), await us.create_combo(legs) + assert first.market_id == second.market_id and route.call_count == 2 + assert body(route) == json.loads(route.calls[0].request.content) + + async def test_legs_checked_before_sending(self, us, venue): + route = venue.post("/v1/combos") + with pytest.raises(InvalidOrder, match="2 to 50 legs"): + await us.create_combo([ComboLeg(market_id=ATL, outcome="yes")]) + with pytest.raises(InvalidOrder, match="belongs to kalshi"): + await us.create_combo([ComboLeg(market_id=ATL, outcome="yes"), ComboLeg(market_id="kalshi:KXA", outcome="yes")]) + with pytest.raises(InvalidOrder, match="each market once"): + await us.create_combo([ComboLeg(market_id=ATL, outcome="yes"), ComboLeg(market_id=f"polymarket_us:{ATL}", outcome="no")]) + assert not route.called + + async def test_venue_rejection_is_invalid_order(self, us, venue): + venue.post("/v1/combos").respond(400, json={"code": 3, "message": "legs [0 1] cannot all settle as selected", "details": []}) + with pytest.raises(InvalidOrder, match="cannot all settle as selected"): + await us.create_combo([ComboLeg(market_id=ATL, outcome="yes"), ComboLeg(market_id=NYY, outcome="yes")]) + + async def test_fetch_by_symbol(self, us, venue): + route = venue.get("/v1/combos").respond(json=sample("get_combos")) + combo = await us.fetch_combo(f"polymarket_us:{COMBO}") + assert query(route) == {"symbol": COMBO} and len(combo.legs) == 2 + + async def test_unknown_symbol_is_not_found(self, us, venue): + venue.get("/v1/combos").respond(json=sample("get_combos_empty")) + with pytest.raises(RfqNotFound, match="no combo"): + await us.fetch_combo("caoc-nope") + + async def test_a_collection_is_refused_before_sending(self, us, venue): + route = venue.post("/v1/combos") + with pytest.raises(InvalidOrder, match="not built in collections"): + await us.create_combo([ComboLeg(market_id=ATL, outcome="yes"), ComboLeg(market_id=NYY, outcome="no")], + collection_id="KXMVECROSSCATEGORY-R") + assert not route.called + + async def test_no_read_only_lookup(self, us): + with pytest.raises(NotSupported, match="create_combo returns the existing combo"): + await us.lookup_combo([ComboLeg(market_id=ATL, outcome="yes"), ComboLeg(market_id=NYY, outcome="no")]) + + +# -- requester (taker) side -------------------------------------------------------- + + +class TestCreateRfq: + async def test_by_contracts(self, us, venue): + route = venue.post("/v1/rfqs").respond(json=sample("create_rfq")) + rfq = await us.create_rfq(f"polymarket_us:{COMBO}", quantity=D("25.50"), rest_remainder=True) + assert body(route) == {"symbol": COMBO, "restRemainder": True, "qtyDecimal": "25.5"} + assert isinstance(rfq, Rfq) and rfq.id == "rfq_01" and rfq.status == RfqStatus.OPEN and rfq.mine + assert rfq.combo_id == f"polymarket_us:{COMBO}" and rfq.market_id is None and rfq.quantity == D("25.50") + + async def test_by_amount_is_cash_order_qty(self, us, venue): + route = venue.post("/v1/rfqs").respond(json=sample("create_rfq")) + legs = sample("create_combo")["combo"] + combo = retail.combo_of(legs) + rfq = await us.create_rfq(combo.market_id, amount=D("10.00"), combo=combo) + assert body(route) == {"symbol": COMBO, "restRemainder": False, "cashOrderQty": "10"} + assert rfq.amount == D("10") and rfq.quantity is None and rfq.combo is combo + + async def test_a_single_market(self, us, venue): + venue.post("/v1/rfqs").respond(json=sample("create_rfq")) + rfq = await us.create_rfq(ATL, quantity=D("5")) + assert rfq.market_id == f"polymarket_us:{ATL}" and rfq.combo_id is None + + async def test_refusals_send_nothing(self, us, venue): + route = venue.post("/v1/rfqs") + with pytest.raises(InvalidOrder, match="exactly one"): + await us.create_rfq(COMBO, quantity=D("1"), amount=D("1")) + with pytest.raises(InvalidOrder, match="exactly one"): + await us.create_rfq(COMBO) + with pytest.raises(InvalidOrder, match="two-sided"): + await us.create_rfq(COMBO, quantity=D("1"), outcome="yes", action="buy") + with pytest.raises(InvalidOrder, match="positive"): + await us.create_rfq(COMBO, amount=D("-1")) + assert not route.called + + +class TestReadRfqs: + async def test_fetch_rfq_normalises_the_documented_rfq(self, us, venue): + route = venue.get("/v1/rfqs").respond(json={"rfqs": [sample("get_rfqs")["rfqs"][0]], "cursor": ""}) + rfq = await us.fetch_rfq("rfq_01") + assert query(route) == {"rfqId": "rfq_01"} + assert rfq.mine and rfq.amount == D("10.0000") and rfq.quantity is None and not rfq.rest_remainder + assert rfq.combo_id == f"polymarket_us:{COMBO}" and rfq.combo is not None + assert [(l.market_id, l.outcome) for l in rfq.combo.legs] == [(f"polymarket_us:{ATL}", "yes"), (f"polymarket_us:{NYY}", "no")] + assert rfq.combo.legs[1].info["settlementPrice"] == "0" # raw kept, not inverted + assert rfq.created_at == 1785333600000 and rfq.info["tickSize"] == 0.001 and rfq.account == us.account + + async def test_fetch_rfq_missing(self, us, venue): + venue.get("/v1/rfqs").respond(json=sample("get_rfqs_empty")) + with pytest.raises(RfqNotFound): + await us.fetch_rfq("rfq_nope") + + async def test_fetch_rfqs_is_mine(self, us, venue): + route = venue.get("/v1/rfqs").respond(json={"rfqs": [sample("get_rfqs")["rfqs"][0]], "cursor": "next-1"}) + page = await us.fetch_rfqs(status="open", market_id=COMBO, limit=10) + assert query(route) == {"userFilter": "USER_FILTER_SELF", "status": "RFQ_STATUS_OPEN", "symbol": COMBO, "limit": "10"} + assert [r.id for r in page] == ["rfq_01"] and page.next_cursor == "next-1" and page[0].mine + + async def test_closed_status(self, us, venue): + closed = {**sample("get_rfqs")["rfqs"][0], "status": "RFQ_STATUS_CLOSED"} + venue.get("/v1/rfqs").respond(json={"rfqs": [closed]}) + rfq = await us.fetch_rfq("rfq_01") + assert rfq.status == RfqStatus.CLOSED and rfq.closed_reason is None # the venue gives no reason + + async def test_cancel_rfq(self, us, venue): + route = venue.delete("/v1/rfqs/rfq_01").respond(json=sample("empty")) + assert await us.cancel_rfq("rfq_01") is None + assert route.called + + +class TestQuotes: + async def test_price_mapping(self, us, venue): + route = venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes")) + two, one = await us.fetch_quotes("rfq_01") + assert query(route) == {"rfqId": "rfq_01", "limit": "100"} + assert isinstance(two, RfqQuote) and two.status == QuoteStatus.OPEN and not two.mine + # buyPrice is what the requester buys YES at: our ask; sellPrice our bid. + assert two.ask == D("0.615") and two.bid == D("0.585") + assert two.ask_quantity == D("16.26") and two.bid_quantity == D("24.09") + assert two.price_for("yes", "buy") == D("0.615") and two.price_for("no", "buy") == D("0.585") + assert two.price_for("yes", "sell") == D("0.585") and two.price_for("no", "sell") == D("0.615") + assert two.market_id == f"polymarket_us:{COMBO}" and two.info["postOnly"] is True + # "0" marks a side the maker declined. + assert one.ask == D("0.62") and one.bid is None and one.bid_quantity is None + + async def test_status_filter(self, us, venue): + route = venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes")) + await us.fetch_quotes("rfq_01", status=QuoteStatus.OPEN) + assert query(route)["status"] == "QUOTE_STATUS_ACTIVE" + with pytest.raises(Exception, match="no 'expired' status"): + await us.fetch_quotes("rfq_01", status="expired") + + async def test_pages_follow_the_cursor(self, us, venue): + quotes = sample("get_quotes")["quotes"] + route = venue.get("/v1/rfqs/quotes") + route.side_effect = [ + httpx.Response(200, json={"quotes": quotes[:1], "cursor": "c1"}), + httpx.Response(200, json={"quotes": quotes[1:], "cursor": ""}), + ] + assert [q.id for q in await us.fetch_quotes("rfq_01")] == ["quote_01", "quote_02"] + assert route.calls[1].request.url.params["cursor"] == "c1" + + async def test_executed_records_both_order_ids(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes_executed")) + quote = await us.fetch_quote("quote_01", rfq_id="rfq_01") + assert quote.status == QuoteStatus.EXECUTED and quote.is_terminal + assert quote.taker_order_id == "ord_requester_01" and quote.maker_order_id == "ord_maker_01" + assert quote.accepted_side == Side.BUY + assert quote.confirm_by == 1785333604000 and quote.execute_by == 1785333603000 + + async def test_deleted_reads_as_canceled(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes_deleted")) + assert (await us.fetch_quote("quote_01", rfq_id="rfq_01")).status == QuoteStatus.CANCELED + + async def test_fetch_quote_needs_its_rfq(self, us, venue): + with pytest.raises(InvalidOrder, match="give rfq_id"): + await us.fetch_quote("quote_01") + venue.get("/v1/rfqs/quotes").respond(json={"quotes": [], "cursor": ""}) + with pytest.raises(RfqNotFound): + await us.fetch_quote("quote_xx", rfq_id="rfq_01") + + +class TestAccept: + @pytest.mark.parametrize("outcome, action, wire", [ + ("yes", "buy", "SIDE_BUY"), # buy YES at buyPrice + ("no", "sell", "SIDE_BUY"), # selling NO is a YES buy + ("yes", "sell", "SIDE_SELL"), # sell YES at sellPrice + ("no", "buy", "SIDE_SELL"), # buying NO is a YES sell (short) on this netting venue + ]) + async def test_both_sides(self, us, venue, outcome, action, wire): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes")) + route = venue.put("/v1/rfqs/rfq_01/quotes/quote_01/accept").respond(json=sample("empty")) + quote = await us.accept_quote(accept(outcome=outcome, action=action)) + assert body(route) == {"acceptedSide": wire} + assert quote.status == QuoteStatus.ACCEPTED and quote.accepted_side == Side(wire[5:].lower()) + assert quote.info["accept"]["client_order_id"] == "rfq-accept:polymarket_us:quote_01" + assert quote.info["accept"]["resent"] is True + + async def test_worst_price_refuses_before_sending(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes")) + route = venue.put("/v1/rfqs/rfq_01/quotes/quote_01/accept") + with pytest.raises(InvalidOrder, match="worse than the worst price 0.6"): + await us.accept_quote(accept(worst_price=D("0.60"))) # ask 0.615 > 0.60 + with pytest.raises(InvalidOrder, match="worse than the worst price 0.59"): + await us.accept_quote(accept(action="sell", worst_price=D("0.59"))) # bid 0.585 < 0.59 + with pytest.raises(InvalidOrder, match="worse"): + await us.accept_quote(accept(outcome="no", action="buy", worst_price=D("0.59"))) # NO at 0.415, YES 0.585 + assert not route.called + + async def test_worst_price_met_is_sent(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes")) + route = venue.put("/v1/rfqs/rfq_01/quotes/quote_01/accept").respond(json={}) + await us.accept_quote(accept(worst_price=D("0.615"), client_order_id="mine-1")) + assert route.called + + async def test_declined_side_is_refused(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes")) + route = venue.put("/v1/rfqs/rfq_01/quotes/quote_02/accept") + with pytest.raises(InvalidOrder, match="declines the sell side"): + await us.accept_quote(accept(quote_id="quote_02", action="sell")) + assert not route.called + + async def test_retry_after_accept_does_not_resend(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes_accepted")) + route = venue.put("/v1/rfqs/rfq_01/quotes/quote_01/accept") + quote = await us.accept_quote(accept()) + assert not route.called and quote.status == QuoteStatus.ACCEPTED and quote.info["accept"]["resent"] is False + + async def test_retry_after_execution_returns_the_order_id(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes_executed")) + route = venue.put("/v1/rfqs/rfq_01/quotes/quote_01/accept") + quote = await us.accept_quote(accept()) + assert not route.called and quote.taker_order_id == "ord_requester_01" + + async def test_accepted_on_the_other_side_is_refused(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes_accepted")) + with pytest.raises(OrderRejected, match="already accepted") as caught: + await us.accept_quote(accept(action="sell")) + assert caught.value.reason == "already_accepted" + + async def test_inactive_quote_is_refused(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes_deleted")) + route = venue.put("/v1/rfqs/rfq_01/quotes/quote_01/accept") + with pytest.raises(OrderRejected, match="QUOTE_STATUS_DELETED, not active"): + await us.accept_quote(accept()) + assert not route.called + + async def test_full_size_only_and_rfq_id_needed(self, us): + with pytest.raises(InvalidOrder, match="give rfq_id"): + await us.accept_quote(accept(rfq_id=None)) + with pytest.raises(InvalidOrder, match="full size"): + await us.accept_quote(accept(quantity=D("5"))) + with pytest.raises(InvalidOrder, match="full size"): + await us.accept_quote(accept(amount=D("5"))) + + +# -- quoter (maker) side ----------------------------------------------------------- + + +class TestQuoter: + async def test_incoming_leaves_out_our_own(self, us, venue): + route = venue.get("/v1/rfqs").respond(json=sample("get_rfqs")) + page = await us.fetch_incoming_rfqs() + assert query(route) == {"status": "RFQ_STATUS_OPEN"} + assert [r.id for r in page] == ["rfq_02"] + rfq = page[0] + assert not rfq.mine and rfq.account is None and rfq.quantity == D("25") and rfq.rest_remainder + assert rfq.combo_id == "polymarket_us:crossd-example" and rfq.combo is None # no inline legs: prefix + + async def test_send_quote_maps_ask_and_bid(self, us, venue): + route = venue.post("/v1/rfqs/quotes").respond(json=sample("create_quote")) + quote = await us.send_quote(MakerQuote(rfq_id="rfq_02", bid=D("0.5850"), ask=D("0.615"), post_only=True, client_order_id="c1")) + assert body(route) == {"rfqId": "rfq_02", "buyPrice": "0.615", "sellPrice": "0.585", "restRemainder": False, "postOnly": True} + assert quote.id == "quote_09" and quote.mine and quote.status == QuoteStatus.OPEN + assert quote.ask == D("0.615") and quote.bid == D("0.5850") and quote.info["client_order_id"] == "c1" + + async def test_one_sided_quote_sends_zero(self, us, venue): + route = venue.post("/v1/rfqs/quotes").respond(json=sample("create_quote")) + await us.send_quote(MakerQuote(rfq_id="rfq_02", bid=D("0.40"), rest_remainder=True)) + assert body(route) == {"rfqId": "rfq_02", "buyPrice": "0", "sellPrice": "0.4", "restRemainder": True} + + async def test_unsupported_fields_refused(self, us, venue): + route = venue.post("/v1/rfqs/quotes") + with pytest.raises(InvalidOrder, match="full size"): + await us.send_quote(MakerQuote(rfq_id="r", bid=D("0.4"), quantity=D("5"))) + with pytest.raises(InvalidOrder, match="no expiry"): + await us.send_quote(MakerQuote(rfq_id="r", bid=D("0.4"), expires_at=1)) + assert not route.called + + async def test_replace_gets_a_new_id(self, us, venue): + route = venue.post("/v1/rfqs/quotes").respond(json={"quoteId": "quote_10"}) + new = await us.replace_quote("quote_09", MakerQuote(rfq_id="rfq_02", bid=D("0.58"), ask=D("0.61"))) + assert new.id == "quote_10" and new.info["replaces"] == "quote_09" and body(route)["rfqId"] == "rfq_02" + + async def test_cancel_quote(self, us, venue): + route = venue.delete("/v1/rfqs/rfq_02/quotes/quote_09").respond(json={}) + assert await us.cancel_quote("quote_09", rfq_id="rfq_02") is None and route.called + with pytest.raises(InvalidOrder, match="give rfq_id"): + await us.cancel_quote("quote_09") + + async def test_accepted_then_confirm(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes_mine_accepted")) + [mine] = await us.fetch_my_quotes() + assert mine.mine and mine.account == us.account and mine.status == QuoteStatus.ACCEPTED + assert mine.accepted_side == Side.SELL # the requester sells: we buy at our bid + assert mine.price_for("yes", "sell") == D("0.585") and mine.confirm_by == 1785333664000 + route = venue.put("/v1/rfqs/rfq_02/quotes/quote_09/confirm").respond(json={}) + confirmed = await us.confirm_quote(mine) + assert body(route) == {} and confirmed.status == QuoteStatus.CONFIRMED + + async def test_my_quotes_filters(self, us, venue): + route = venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes_mine")) + await us.fetch_my_quotes(status="open") + assert query(route) == {"userFilter": "USER_FILTER_SELF", "status": "QUOTE_STATUS_ACTIVE", "limit": "100"} + await us.fetch_my_quotes(rfq_id="rfq_02") + assert query(route) == {"rfqId": "rfq_02", "limit": "100"} + + async def test_mine_from_the_user_id(self, us, venue): + user = venue.get("/v1/rfqs/user-id").respond(json=sample("user_id")) + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes_mine")) + [quote] = await us.fetch_quotes("rfq_02") + assert quote.mine and quote.ask_quantity == D("25") + await us.fetch_quotes("rfq_02") + assert user.call_count == 1 # read once and kept + + +# -- errors ------------------------------------------------------------------------ + + +class TestErrors: + async def test_not_found(self, us, venue): + venue.delete("/v1/rfqs/rfq_x").respond(404, json={"code": 5, "message": "rfq not found"}) + with pytest.raises(RfqNotFound, match="rfq not found"): + await us.cancel_rfq("rfq_x") + + async def test_bad_request_is_rejected(self, us, venue): + venue.post("/v1/rfqs/quotes").respond(400, json={"code": 3, "message": "buyPrice is not aligned to tick 0.001"}) + with pytest.raises(OrderRejected, match="not aligned") as caught: + await us.send_quote(MakerQuote(rfq_id="rfq_02", ask=D("0.6155"))) + assert caught.value.reason == "3" + + async def test_insufficient_funds(self, us, venue): + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes")) + venue.put("/v1/rfqs/rfq_01/quotes/quote_01/accept").respond(400, json={"code": 9, "message": "insufficient buying power"}) + with pytest.raises(InsufficientFunds): + await us.accept_quote(accept()) + + async def test_auth(self, us, venue): + venue.post("/v1/rfqs").respond(401, json={"message": "invalid signature"}) + with pytest.raises(AuthenticationError): + await us.create_rfq(COMBO, quantity=D("1")) + + async def test_rate_limited(self, us, venue, monkeypatch): + import asyncio + + async def no_sleep(_): + return None + monkeypatch.setattr(asyncio, "sleep", no_sleep) + route = venue.post("/v1/combos").respond(429, json={"status": 429, "message": "Too Many Requests"}) + with pytest.raises(RateLimitExceeded): + await us.create_combo([ComboLeg(market_id=ATL, outcome="yes"), ComboLeg(market_id=NYY, outcome="no")]) + assert route.call_count == 4 # the HTTP client's retries on 429 only + + +# -- tolerant normalisers (P3 fix) ------------------------------------------------- + + +class TestTolerantNormalisers: + """One crossed or malformed quote from the venue must not break the list.""" + + async def test_a_crossed_quote_is_kept_flagged_and_a_broken_one_skipped(self, us, venue, caplog): + rows = sample("get_quotes")["quotes"] + crossed = {**rows[0], "id": "quote_crossed", "buyPrice": "0.55", "sellPrice": "0.60"} + broken = {**rows[0], "id": "quote_broken", "buyPrice": "0", "sellPrice": "0"} + venue.get("/v1/rfqs/quotes").respond(json={"quotes": [crossed, broken, rows[1]], "cursor": ""}) + with caplog.at_level("WARNING", logger="synpath.trading.polymarket_us"): + quotes = await us.fetch_quotes("rfq_01") + assert [q.id for q in quotes] == ["quote_crossed", "quote_02"] + assert quotes[0].crossed is True and quotes[0].bid == D("0.60") and quotes[0].ask == D("0.55") + assert quotes[1].crossed is False + messages = " ".join(r.getMessage() for r in caplog.records) + assert "crossed" in messages and "quote_broken" in messages + + def test_a_user_built_crossed_quote_is_still_refused(self): + with pytest.raises(ValueError, match="above ask"): + RfqQuote(id="q", rfq_id="r", venue="polymarket_us", bid=D("0.6"), ask=D("0.5")) + + async def test_a_broken_rfq_is_skipped(self, us, venue, caplog): + good = sample("get_rfqs")["rfqs"][0] + broken = {**good, "id": "rfq_broken", "qtyDecimal": "0", "cashOrderQty": "0", + "rfqCreatorUserId": "rfquser_me"} + venue.get("/v1/rfqs").respond(json={"rfqs": [broken, good], "cursor": ""}) + with caplog.at_level("WARNING", logger="synpath.trading.polymarket_us"): + page = await us.fetch_rfqs() + assert [r.id for r in page] == [good["id"]] + assert "rfq_broken" in " ".join(r.getMessage() for r in caplog.records) + + +# -- per-endpoint pacing ------------------------------------------------------------- + + +class FakeClock: + """`time.monotonic` and `asyncio.sleep` for the limiter: sleeping moves the clock.""" + + def __init__(self): + self.now = 1000.0 + self.slept: list[float] = [] + + def monotonic(self) -> float: + return self.now + + async def sleep(self, seconds: float) -> None: + self.slept.append(seconds) + self.now += seconds + + +class TestPacing: + """The venue limits its RFQ lists and creates to one request a second per + endpoint; the adapter paces them so the engine's follower never trips a 429.""" + + @pytest.fixture + def clock(self, monkeypatch): + import types + + from synpath.trading import limiter as limiter_module + + clock = FakeClock() + monkeypatch.setattr(limiter_module, "time", types.SimpleNamespace(monotonic=clock.monotonic)) + monkeypatch.setattr(limiter_module, "asyncio", types.SimpleNamespace(sleep=clock.sleep)) + return clock + + @pytest.fixture + def paced(self, venue, ed_key, clock): + raw = ed_key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, + serialization.NoEncryption()) + return retail.PolymarketUSTrading( + PolymarketUSCredentials(key_id="key-1", secret_key=base64.b64encode(raw).decode()), + client=httpx.AsyncClient(), limiter=BudgetLimiter(read_per_second=1000, write_per_second=1000), + ) + + def stamps(self, route, clock, payload): + times: list[float] = [] + route.mock(side_effect=lambda request: (times.append(clock.now), httpx.Response(200, json=payload))[1]) + return times + + async def test_quote_reads_go_out_one_a_second(self, paced, venue, clock): + times = self.stamps(venue.get("/v1/rfqs/quotes"), clock, sample("get_quotes")) + for _ in range(4): + await paced.fetch_quotes("rfq_01") + assert len(times) == 4 + gaps = [b - a for a, b in zip(times, times[1:])] + assert all(gap >= 0.999 for gap in gaps), gaps + + async def test_rfq_and_combo_creates_share_one_pace(self, paced, venue, clock): + times: list[float] = [] + venue.post("/v1/combos").mock(side_effect=lambda r: (times.append(clock.now), + httpx.Response(200, json=sample("create_combo")))[1]) + venue.post("/v1/rfqs").mock(side_effect=lambda r: (times.append(clock.now), + httpx.Response(200, json=sample("create_rfq")))[1]) + legs = [ComboLeg(market_id=f"polymarket_us:{ATL}", outcome="yes"), + ComboLeg(market_id=f"polymarket_us:{NYY}", outcome="no")] + await paced.create_combo(legs) + await paced.create_rfq(f"polymarket_us:{COMBO}", amount=D("10")) + assert len(times) == 2 and times[1] - times[0] >= 0.999 + + async def test_groups_do_not_wait_on_each_other_and_writes_are_not_paced(self, paced, venue, clock): + venue.get("/v1/rfqs").respond(json=sample("get_rfqs")) + venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes")) + venue.delete("/v1/rfqs/rfq_01").respond(json=sample("empty")) + start = clock.now + await paced.fetch_rfqs() + await paced.fetch_quotes("rfq_01") + await paced.cancel_rfq("rfq_01") + await paced.cancel_rfq("rfq_01") + assert clock.now == start, "one call per group, and the cancels, go at once" + + async def test_a_queue_longer_than_the_deadline_refuses_before_sending(self, paced, venue, clock): + from synpath.trading.errors import RateBudgetExceeded + + route = venue.get("/v1/rfqs/quotes").respond(json=sample("get_quotes")) + pacer = paced.pacers["rfq_quotes"] + for _ in range(int(retail.PACE_MAX_WAIT_S) + 2): + pacer.reserve(1, "write") # others already queued in this group + with pytest.raises(RateBudgetExceeded): + await paced.fetch_quotes("rfq_01") + assert not route.called + + def test_pacing_can_be_turned_off(self, ed_key): + raw = ed_key.private_bytes(serialization.Encoding.Raw, serialization.PrivateFormat.Raw, + serialization.NoEncryption()) + us = retail.PolymarketUSTrading(PolymarketUSCredentials(key_id="k", secret_key=base64.b64encode(raw).decode()), + client=httpx.AsyncClient(), rfq_pace_per_second=None) + assert us.pacers == {} + assert set(retail.PACED_ENDPOINTS) == {("GET", "/v1/rfqs"), ("GET", "/v1/rfqs/quotes"), + ("POST", "/v1/rfqs"), ("POST", "/v1/combos")} + + +# -- combo-eligible markets (public gateway) --------------------------------------- + + +class TestEligibleMarkets: + """`GET /v1/events` on the public gateway, markets nested; a trimmed real + sample (`polymarket_us_combo_events.json`, read unauthenticated + 2026-10-22): one event with two combo-enabled markets, one with one, + and one with none.""" + + @pytest.fixture + def gateway(self): + with respx.mock(base_url=retail.GATEWAY_URL, assert_all_called=False) as mock: + yield mock + + def events(self) -> dict: + return json.loads((SAMPLES / "polymarket_us_combo_events.json").read_text()) + + async def test_rows_and_query(self, us, gateway): + route = gateway.get("/v1/events").respond(json=self.events()) + page = await us.fetch_combo_eligible_markets(limit=3) + assert query(route) == {"limit": "3", "active": "true", "closed": "false", "orderBy": "id", + "orderDirection": "desc"} + assert "X-PM-Signature" not in route.calls.last.request.headers, "the gateway is public" + assert [r.market_id for r in page] == ["polymarket_us:atc-lal-fcb-rma-2026-10-25-fcb", + "polymarket_us:atc-lal-fcb-rma-2026-10-25-draw", + "polymarket_us:aec-nfl-ne-chi-2026-10-22"] + first = page[0] + assert first.event_id == "polymarket_us:lal-fcb-rma-2026-10-25" and first.event_title + assert first.title.startswith("Will FC Barcelona win") and first.venue == "polymarket_us" + assert (first.min_legs, first.max_legs) == (2, retail.MAX_COMBO_LEGS) and first.position_ids is None + assert "lal" in first.tags and first.status == "MARKET_STATUS_HALTED" + assert first.info["comboEnabled"] is True and first.info["event"]["slug"] == "lal-fcb-rma-2026-10-25" + assert page.next_cursor == "3", "a full page of events has a next one" + + async def test_filters_and_cursor(self, us, gateway): + route = gateway.get("/v1/events").respond(json={"events": self.events()["events"][:1]}) + page = await us.fetch_combo_eligible_markets(tag="lal", limit=5, cursor="10") + assert query(route)["tagSlug"] == "lal" and query(route)["offset"] == "10" + assert len(page) == 2 and page.next_cursor is None, "a short page of events is the last" + await us.fetch_combo_eligible_markets(event_id="polymarket_us:lal-fcb-rma-2026-10-25") + assert query(route)["slug"] == "lal-fcb-rma-2026-10-25" and query(route)["limit"] == "50" + + async def test_an_empty_page_can_carry_a_cursor(self, us, gateway): + gateway.get("/v1/events").respond(json={"events": self.events()["events"][2:]}) + page = await us.fetch_combo_eligible_markets(limit=1) + assert list(page) == [] and page.next_cursor == "1" + + async def test_a_foreign_cursor_is_refused(self, us, gateway): + from synpath.errors import BadRequest + with pytest.raises(BadRequest, match="not a cursor"): + await us.fetch_combo_eligible_markets(cursor="abc") diff --git a/tests/test_polymarket_us_stop_lifecycle.py b/tests/test_polymarket_us_stop_lifecycle.py new file mode 100644 index 0000000..c6a5ccf --- /dev/null +++ b/tests/test_polymarket_us_stop_lifecycle.py @@ -0,0 +1,171 @@ +"""US asynchronous stop-market fills and native close, over mocked HTTP.""" +import json +from decimal import Decimal as D + +import httpx +import pytest + +from examples.verify_polymarket_us_stop_fill import Guard, book_for, verify, QUANTITY +from synpath.trading.polymarket_us import PolymarketUSTrading, MarketRules +from synpath.trading.credentials import PolymarketUSCredentials +from test_polymarket_us_trading import ed_key, ed_secret + +pytestmark = pytest.mark.anyio +SLUG = "btc-test-15m" + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +class Venue: + def __init__(self): + self.posts = [] + self.books = 0 + self.position = D("0") + self.orders = {} + + def handle(self, request): + path = request.url.path + if path == f"/v1/markets/{SLUG}/book": + self.books += 1 + ask = "0.40" if self.books == 1 else "0.42" + return httpx.Response(200, json={"marketData": {"bids": [{"px": {"value": "0.39"}, "qty": "10"}], + "offers": [{"px": {"value": ask}, "qty": "10"}]}}) + if request.method == "POST" and path in ("/v1/orders", "/v1/order/close-position"): + self.posts.append((path, json.loads(request.content))) + entry = path == "/v1/orders" + oid = "entry" if entry else "close" + self.position = QUANTITY if entry else D("0") + self.orders[oid] = {"id": oid, "marketSlug": SLUG, "outcomeSide": "OUTCOME_SIDE_YES", + "action": "ORDER_ACTION_BUY" if entry else "ORDER_ACTION_SELL", + "quantity": str(QUANTITY), "cumQuantity": str(QUANTITY), "leavesQuantity": 0, + "state": "ORDER_STATE_FILLED", "type": "ORDER_TYPE_LIMIT", + "price": {"value": "0.99" if entry else "0.39"}, + "avgPx": {"value": "0.42" if entry else "0.39"}, + "commissionNotionalTotalCollected": {"value": "0.0002"}} + return httpx.Response(200, json={"id": oid}) # receipt, not a fill confirmation + if request.method == "GET" and path.startswith("/v1/order/"): + return httpx.Response(200, json={"order": self.orders[path.split("/")[-1]]}) + if path == "/v1/portfolio/positions": + return httpx.Response(200, json={"positions": {SLUG: {"netPositionDecimal": str(self.position)}} if self.position else {}, "eof": True}) + raise AssertionError(f"unexpected call {request.method} {path}") + + +async def test_fractional_stop_market_waits_crosses_fills_and_closes_only_new_position(ed_secret, tmp_path): + venue, guard = Venue(), Guard(SLUG, tmp_path) + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle), + event_hooks={"request": [guard.check], "response": [guard.observe]}) as client: + async with PolymarketUSTrading(PolymarketUSCredentials("mock-key", ed_secret), client=client) as adapter: + rules = MarketRules(SLUG, D("0.01"), QUANTITY) + adapter.remember_market(rules) + report = await verify(adapter, guard, tmp_path, rules, await book_for(adapter, SLUG), D("0.41"), wait_s=2, poll_s=0) + assert report["outcome"] == "filled and closed" and report["remaining_position_contracts"] == "0" + assert report["initial_ask"] == "0.40" and report["observed_trigger_ask"] == "0.42" + assert report["entry_filled"] == "0.01" and report["entry_average_price"] == "0.42" + assert report["parent_status"] == "closed" and report["exit_filled"] == "0.01" + assert [p for p, _ in venue.posts] == ["/v1/orders", "/v1/order/close-position"] + assert venue.posts[0][1]["tif"] == "TIME_IN_FORCE_IMMEDIATE_OR_CANCEL" + assert venue.posts[0][1]["price"]["value"] == "0.99" + assert json.loads((tmp_path / "receipts.json").read_text()) == {"entry": "entry", "close": "close"} + + +@pytest.mark.parametrize("side,bound", [("buy", "0.99"), ("sell", "0.01")]) +async def test_subcent_market_grid_still_respects_us_absolute_bounds(ed_secret, tmp_path, side, bound): + from synpath.engine import Engine, EngineConfig, RiskConfig + from synpath.trading.types import OrderRequest + from types import SimpleNamespace + venue = Venue() + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle)) as client: + async with PolymarketUSTrading(PolymarketUSCredentials("mock-key", ed_secret), client=client) as adapter: + adapter.remember_market(MarketRules(SLUG, D("0.001"), QUANTITY)) + engine = Engine({"polymarket_us": adapter}, EngineConfig(journal_path=str(tmp_path / "engine.db")), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None)) + await engine.start() + try: + await engine.submit(OrderRequest(market_id=f"polymarket_us:{SLUG}", side=side, quantity=QUANTITY, + type="stop_market", stop_price="0.40")) + engine.set_book(f"polymarket_us:{SLUG}", SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.41"))) + await engine.on_book({"market_id": f"polymarket_us:{SLUG}"}) + assert D(venue.posts[0][1]["price"]["value"]) == D(bound) + finally: + await engine.stop() + + +@pytest.mark.parametrize("change", [{"quantity": 1}, {"price": {"value": "0.999", "currency": "USD"}}, + {"action": "ORDER_ACTION_SELL"}, {"tif": "TIME_IN_FORCE_GOOD_TILL_CANCEL"}]) +async def test_guard_rejects_larger_wrong_side_or_resting_entries(tmp_path, change): + guard = Guard(SLUG, tmp_path) + guard.stage = "entry" + body = dict(marketSlug=SLUG, quantity=0.01, price={"value": "0.99", "currency": "USD"}, type="ORDER_TYPE_LIMIT", + outcomeSide="OUTCOME_SIDE_YES", action="ORDER_ACTION_BUY", tif="TIME_IN_FORCE_IMMEDIATE_OR_CANCEL") + body.update(change) + with pytest.raises(ValueError): + await guard.check(httpx.Request("POST", "https://api.polymarket.us/v1/orders", json=body)) + assert not guard.sent + + +async def test_price_wait_keeps_the_execution_lease_alive(ed_secret, tmp_path, monkeypatch): + import asyncio + import examples.verify_polymarket_us_stop_fill as check + from synpath.engine import EngineConfig, Engine, RiskConfig + venue, guard = Venue(), Guard(SLUG, tmp_path) + original = check.engine_for + def brief_lease(adapter, directory): + engine = original(adapter, directory) + # Wide margins: on a slow CI runner a 20 ms renewal against a 150 ms + # lease could arrive late and drop the lease for an unrelated reason. + engine.config.lease_ttl_ms = 600 + engine.config.lease_renew_s = 0.05 + return engine + original_book = check.book_for + async def delayed_book(adapter, slug): + await asyncio.sleep(0.9) # exceeds the lease without its renewal task + return await original_book(adapter, slug) + monkeypatch.setattr(check, "engine_for", brief_lease) + monkeypatch.setattr(check, "book_for", delayed_book) + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle), + event_hooks={"request": [guard.check], "response": [guard.observe]}) as client: + async with PolymarketUSTrading(PolymarketUSCredentials("mock-key", ed_secret), client=client) as adapter: + rules = MarketRules(SLUG, D("0.01"), QUANTITY) + adapter.remember_market(rules) + initial = await original_book(adapter, SLUG) + report = await check.verify(adapter, guard, tmp_path, rules, initial, D("0.41"), wait_s=2, poll_s=0) + assert report["outcome"] == "filled and closed" and len(venue.posts) == 2 + + +async def test_receipt_hook_reads_a_streamed_response_before_decoding(tmp_path): + class StreamingBody(httpx.AsyncByteStream): + async def __aiter__(self): + yield b'{"id":"streamed-entry"}' + guard = Guard(SLUG, tmp_path) + response = httpx.Response(200, stream=StreamingBody(), + request=httpx.Request("POST", "https://api.polymarket.us/v1/orders")) + assert not response.is_stream_consumed + await guard.observe(response) + assert response.is_stream_consumed and guard.ids["entry"] == "streamed-entry" + assert json.loads((tmp_path / "receipts.json").read_text())["entry"] == "streamed-entry" + + +def test_us_file_credentials_override_process_environment(ed_secret, tmp_path, monkeypatch): + from examples.verify_polymarket_us_stop_fill import credentials_from + (tmp_path / ".env").write_text(f"POLYMARKET_US_KEY_ID=file-key\nPOLYMARKET_US_SECRET_KEY={ed_secret}\n") + monkeypatch.setenv("POLYMARKET_US_KEY_ID", "system-key") + monkeypatch.setenv("POLYMARKET_US_SECRET_KEY", "invalid-system-secret") + credential = credentials_from(tmp_path) + assert credential.key_id == "file-key" + + +async def test_already_eligible_stop_uses_next_real_observation(ed_secret, tmp_path): + venue, guard = Venue(), Guard(SLUG, tmp_path) + async with httpx.AsyncClient(transport=httpx.MockTransport(venue.handle), + event_hooks={"request": [guard.check], "response": [guard.observe]}) as client: + async with PolymarketUSTrading(PolymarketUSCredentials("mock-key", ed_secret), client=client) as adapter: + rules = MarketRules(SLUG, D("0.01"), QUANTITY) + adapter.remember_market(rules) + initial = await book_for(adapter, SLUG) + assert venue.posts == [] + report = await verify(adapter, guard, tmp_path, rules, initial, D("0.01"), initially_eligible=True, wait_s=2, poll_s=0) + assert report["outcome"] == "filled and closed" and report["trigger_mode"] == "initially eligible" + assert len(venue.posts) == 2 and report["remaining_position_contracts"] == "0" diff --git a/tests/test_polymarket_us_trading.py b/tests/test_polymarket_us_trading.py index e9739f3..e3ec08a 100644 --- a/tests/test_polymarket_us_trading.py +++ b/tests/test_polymarket_us_trading.py @@ -146,7 +146,6 @@ def test_fractional_contract_market(self): ({"price": D("0.1075")}, "tick"), ({"price": D("0.995")}, "outside"), ({"side": Side.SELL, "price": D("0.995")}, "outside"), - ({"reduce_only": True}, "close_position"), ({"time_in_force": TimeInForce.DAY}, "session roll"), ({"type": OrderType.STOP_LIMIT, "stop_price": D("0.1")}, "execution engine"), ({"type": OrderType.MARKET, "price": None}, "protection price"), @@ -435,7 +434,6 @@ def test_client_order_id_and_gtd(self): ({"type": OrderType.TRAILING_STOP, "stop_price": D("0.1")}, "execution engine"), ({"type": OrderType.MARKET, "price": None}, "protection price"), ({"time_in_force": TimeInForce.DAY}, "roll"), - ({"reduce_only": True}, "reduce-only"), ({"quantity": D("0.5")}, "whole contracts"), ]) def test_refused(self, kw, match): diff --git a/tests/test_polymarket_v3_signing.py b/tests/test_polymarket_v3_signing.py new file mode 100644 index 0000000..b2fa10c --- /dev/null +++ b/tests/test_polymarket_v3_signing.py @@ -0,0 +1,222 @@ +"""Polymarket Exchange v3 order signing (combo RFQs), offline. + +Evidence, kept apart: + +* **Vectors** in `tests/samples/polymarket_v3_signing_vectors.json`, made + without synpath: the typed data exactly as the venue's combo docs print it, + signed by `eth_account`, and the Deposit Wallet signature wrapped by the + docs' `wrapDepositWalletSignature` algorithm. The venue publishes no + Exchange v3 vectors of its own. Every order hash and signature must match + byte for byte, on the Rust core signer and on the `eth_account` path. +* **Recomputation in the test**: the docs' typed data rebuilt here, hashed by + `eth_account`, and each signature recovered to the signer. +* **CLOB V2 untouched**: the V2 vectors still match after this module loads. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from eth_abi import encode +from eth_account import Account +from eth_account.messages import encode_typed_data +from eth_utils import keccak + +from synpath.trading import polymarket_signing as v2 +from synpath.trading import polymarket_v3_signing as v3 +from synpath.trading.errors import InvalidOrder + +SAMPLES = Path(__file__).parent / "samples" +VECTORS = json.loads((SAMPLES / "polymarket_v3_signing_vectors.json").read_text()) +V2_VECTORS = json.loads((SAMPLES / "polymarket_signing_vectors.json").read_text()) +KEY = VECTORS["private_key"] +ADDRESS = VECTORS["address"] +WALLET = VECTORS["wallet"] +CASES = {c["name"]: c for c in VECTORS["orders"]} + +DOCS_DOMAIN = {"name": "Polymarket CTF Exchange", "version": "3", "chainId": 137, + "verifyingContract": "0xe3333700cA9d93003F00f0F71f8515005F6c00Aa"} + + +def signer(native: bool = True) -> v2.WalletSigner: + s = v2.WalletSigner(KEY) + if not native: + s._native = None + return s + + +def docs_message(order: dict) -> dict: + return { + "salt": int(order["salt"]), "maker": order["maker"], "signer": order["signer"], + "tokenId": int(order["tokenId"]), "makerAmount": int(order["makerAmount"]), + "takerAmount": int(order["takerAmount"]), "side": order["side"], "signatureType": order["signatureType"], + "timestamp": int(order["timestamp"]), "metadata": bytes(32), "builder": bytes(32), + } + + +ORDER_FIELDS = [ + {"name": "salt", "type": "uint256"}, {"name": "maker", "type": "address"}, {"name": "signer", "type": "address"}, + {"name": "tokenId", "type": "uint256"}, {"name": "makerAmount", "type": "uint256"}, + {"name": "takerAmount", "type": "uint256"}, {"name": "side", "type": "uint8"}, + {"name": "signatureType", "type": "uint8"}, {"name": "timestamp", "type": "uint256"}, + {"name": "metadata", "type": "bytes32"}, {"name": "builder", "type": "bytes32"}, +] +DOMAIN_FIELDS = [{"name": "name", "type": "string"}, {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, {"name": "verifyingContract", "type": "address"}] + + +def docs_order_typed_data(order: dict) -> dict: + return {"types": {"EIP712Domain": DOMAIN_FIELDS, "Order": ORDER_FIELDS}, "primaryType": "Order", + "domain": DOCS_DOMAIN, "message": docs_message(order)} + + +def docs_deposit_typed_data(order: dict) -> dict: + return { + "types": {"EIP712Domain": DOMAIN_FIELDS, "Order": ORDER_FIELDS, "TypedDataSign": [ + {"name": "contents", "type": "Order"}, {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, {"name": "salt", "type": "bytes32"}]}, + "primaryType": "TypedDataSign", "domain": DOCS_DOMAIN, + "message": {"contents": docs_message(order), "name": "DepositWallet", "version": "1", "chainId": 137, + "verifyingContract": order["signer"], "salt": bytes(32)}, + } + + +def digest_of(typed: dict) -> bytes: + enc = encode_typed_data(full_message=typed) + return keccak(b"\x19" + enc.version + enc.header + enc.body) + + +class TestVectors: + @pytest.mark.parametrize("name", sorted(CASES)) + @pytest.mark.parametrize("native", [True, False], ids=["rust-core", "eth-account"]) + def test_signature_and_hash_match_byte_for_byte(self, name, native): + case = CASES[name] + assert v3.order_hash(case["order"]) == case["order_hash"] + assert v3.sign_order(signer(native), case["order"]) == case["signature"] + + def test_the_rust_core_is_used_when_installed(self): + from synpath import _native + + if _native.core is None: + pytest.skip("the Rust core is not installed") + assert signer()._native is not None + + @pytest.mark.parametrize("name", ["proxy_buy", "safe_sell", "eoa_maker_buy_no"]) + def test_direct_signatures_recover_to_the_signer(self, name): + case = CASES[name] + typed = docs_order_typed_data(case["order"]) + assert v3.signing_digest(case["order"]) == digest_of(typed) + assert Account.recover_message(encode_typed_data(full_message=typed), signature=case["signature"]) == ADDRESS + assert len(bytes.fromhex(case["signature"][2:])) == 65 + + @pytest.mark.parametrize("name", ["deposit_buy", "deposit_sell"]) + def test_deposit_wallet_signature_is_the_erc7739_wrap(self, name): + case = CASES[name] + order = case["order"] + raw = bytes.fromhex(case["signature"][2:]) + inner, separator, contents = raw[:65], raw[65:97], raw[97:129] + type_string, length = raw[129:-2], int.from_bytes(raw[-2:], "big") + typed = docs_deposit_typed_data(order) + assert v3.signing_digest(order) == digest_of(typed) + assert Account.recover_message(encode_typed_data(full_message=typed), signature=inner) == ADDRESS + # The app domain separator is the Exchange v3 domain's hash, as eth_account computes it. + assert separator == encode_typed_data(full_message=docs_order_typed_data(order)).header + assert contents == encode_typed_data(full_message=docs_order_typed_data(order)).body + assert type_string == v3.ORDER_TYPE.encode() and length == len(v3.ORDER_TYPE) + + def test_typed_data_matches_the_docs_payloads(self): + order = CASES["deposit_buy"]["order"] + assert digest_of(v3.deposit_wallet_typed_data(order)) == digest_of(docs_deposit_typed_data(order)) + assert digest_of(v3.order_typed_data(order)) == digest_of(docs_order_typed_data(order)) + assert v3.typed_data_for(order)["primaryType"] == "TypedDataSign" + assert v3.typed_data_for(CASES["proxy_buy"]["order"])["primaryType"] == "Order" + + def test_the_domain(self): + assert v3.domain() == DOCS_DOMAIN + assert v3.domain_separator() == keccak(encode( + ["bytes32", "bytes32", "bytes32", "uint256", "address"], + [keccak(text="EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"), + keccak(text="Polymarket CTF Exchange"), keccak(text="3"), 137, DOCS_DOMAIN["verifyingContract"]])) + + +class TestOrders: + def test_build_order_is_the_wire_shape(self): + order = v3.build_order(maker=WALLET, signer=WALLET, token_id=789, maker_amount=966191, taker_amount=1932381, + side="BUY", signature_type=3, timestamp_s=1773890760, salt=12345) + assert order == CASES["deposit_buy"]["order"] + assert isinstance(order["side"], int) and isinstance(order["tokenId"], str) + + def test_refusals(self): + with pytest.raises(InvalidOrder): + v3.build_order(maker=WALLET, signer=WALLET, token_id=1, maker_amount=0, taker_amount=1, side="BUY", + signature_type=3, timestamp_s=1, salt=1) + with pytest.raises(InvalidOrder): + v3.build_order(maker=WALLET, signer=WALLET, token_id=1, maker_amount=1, taker_amount=1, side="HOLD", + signature_type=3, timestamp_s=1, salt=1) + with pytest.raises(InvalidOrder): + v3.build_order(maker=WALLET, signer=WALLET, token_id=1, maker_amount=1, taker_amount=1, side="BUY", + signature_type=7, timestamp_s=1, salt=1) + + def test_identity_per_wallet_type(self): + assert v3.order_identity(3, signer=ADDRESS, wallet=WALLET) == (WALLET, WALLET) + assert v3.order_identity(1, signer=ADDRESS, wallet=WALLET) == (ADDRESS, WALLET) + assert v3.order_identity(2, signer=ADDRESS, wallet=WALLET) == (ADDRESS, WALLET) + assert v3.order_identity(0, signer=ADDRESS, wallet=WALLET) == (ADDRESS, ADDRESS) + + def test_requesters_refuse_an_eoa(self): + with pytest.raises(InvalidOrder, match="refuse EOA wallets.*UNSUPPORTED_REQUESTER_SIGNATURE_TYPE"): + v3.check_requester(0) + for kind in (1, 2, 3): + v3.check_requester(kind) + + +class TestAmounts: + """The market-makers guide's tables, to the base unit.""" + + def test_collateral_quote_amounts(self): + assert v3.maker_quote_amounts("SELL", 450_000, 1_000_000) == ("yes", 450_000, 1_000_000) + assert v3.maker_quote_amounts("BUY", 450_000, 1_000_000) == ("no", 550_000, 1_000_000) + assert v3.maker_quote_amounts("SELL", 450_000, 500_000) == ("yes", 225_000, 500_000) + assert v3.maker_quote_amounts("BUY", 450_000, 500_000) == ("no", 275_000, 500_000) + # ceil, not round + assert v3.maker_quote_amounts("SELL", 333_333, 3) == ("yes", 1, 3) + assert v3.maker_quote_amounts("BUY", 999_999, 1) == ("no", 1, 1) + + def test_quote_size(self): + assert v3.quote_size("shares", 1_000_000, 450_000) == 1_000_000 + assert v3.quote_size("notional", 1_000_000, 450_000) == 2_222_222 + with pytest.raises(InvalidOrder): + v3.quote_size("lots", 1, 1) + + def test_bad_quotes(self): + for args in (("BUY", 0, 1), ("BUY", 1_000_000, 1), ("SELL", 1, 0), ("HOLD", 1, 1)): + with pytest.raises(InvalidOrder): + v3.maker_quote_amounts(*args) + + def test_implied_price(self): + assert str(v3.implied_price(v3.BUY, 500_000, 1_000_000)) == "0.5" + assert str(v3.implied_price(v3.SELL, 1_000_000, 450_000)) == "0.45" + + def test_approval_calls(self): + calls = v3.combo_approval_calls() + assert [c["target"] for c in calls] == [v3.PUSD, v3.POSITION_MANAGER] + assert calls[0]["data"] == v2.approve_calldata(v3.EXCHANGE_V3) + assert calls[1]["data"] == v2.set_approval_for_all_calldata(v3.EXCHANGE_V3) + + +class TestClobV2Unchanged: + @pytest.mark.parametrize("vector", V2_VECTORS["orders"], + ids=lambda v: f"type{v['signature_type']}-{'negrisk' if v['neg_risk'] else 'std'}-{v['side']}") + def test_v2_vectors_still_match(self, vector): + """Loading the v3 module changes nothing about CLOB V2 orders.""" + order = v2.build_order( + maker=vector["maker"], signer=vector["signer"], token_id=vector["token_id"], + maker_amount=vector["maker_amount"], taker_amount=vector["taker_amount"], side=vector["side"], + signature_type=vector["signature_type"], timestamp_ms=vector["timestamp"], salt=vector["salt"], + expiration=int(vector["expiration"]), builder=vector["builder"], + ) + assert v2.sign_order(v2.WalletSigner(V2_VECTORS["private_key"]), order, neg_risk=vector["neg_risk"]) == vector["signature"] + assert v2.order_hash(order, neg_risk=vector["neg_risk"]) == vector["hash"] + assert v2.EXCHANGE_DOMAIN_VERSION == "2" diff --git a/tests/test_postgres_storage.py b/tests/test_postgres_storage.py new file mode 100644 index 0000000..1dbf3e6 --- /dev/null +++ b/tests/test_postgres_storage.py @@ -0,0 +1,454 @@ +"""Real PostgreSQL contracts, opt-in with SYNPATH_TEST_POSTGRES_DSN. + +The DSN must point at a disposable test database. Each test owns a unique +schema; no public schema, exchange credentials or real venue is used. +Portable SQLite scenarios are reused so both backends face the same claims. +""" +from __future__ import annotations + +import asyncio +import inspect +import os +import subprocess +import sys +import uuid +from decimal import Decimal as D +from pathlib import Path + +import pytest + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.engine.postgres import PostgresJournal +from synpath.engine.storage import JournalStore, create_journal +from synpath.engine.storage_types import LeaseLost +from synpath.errors import BadRequest +from synpath.trading.types import Account +import test_order_storage as storage_scenarios +import test_stop_lifecycle as lifecycle_scenarios +import test_engine_orders as order_scenarios + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +async def database(): + dsn = os.environ.get("SYNPATH_TEST_POSTGRES_DSN") + if not dsn: + pytest.skip("set SYNPATH_TEST_POSTGRES_DSN to a disposable PostgreSQL database") + psycopg = pytest.importorskip("psycopg") + schema = "synpath_test_" + uuid.uuid4().hex + try: + yield dsn, schema + finally: + from psycopg import sql + async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as connection: + await connection.execute(sql.SQL("DROP SCHEMA IF EXISTS {} CASCADE").format(sql.Identifier(schema))) + + +@pytest.fixture +async def journal(database): + dsn, schema = database + async with PostgresJournal(dsn, schema=schema, user_id="owner-a") as store: + yield store + + +PORTABLE_STORAGE = [ + storage_scenarios.test_instruction_is_persisted_before_send_and_survives_venue_update, + storage_scenarios.test_type_record_and_dispatch_intent_rollback_together, + storage_scenarios.test_managed_type_record_preserves_subaccount_identity, + storage_scenarios.test_canceled_bucket_write_rolls_back_and_allows_next_write, +] + + +@pytest.mark.parametrize("scenario", PORTABLE_STORAGE, ids=lambda fn: fn.__name__) +async def test_shared_storage_contract(scenario, journal, monkeypatch): + arguments = {"journal": journal, "monkeypatch": monkeypatch} + await scenario(**{name: arguments[name] for name in inspect.signature(scenario).parameters}) + + +@pytest.mark.parametrize("kind", ["market", "limit", "stop_market", "stop_limit"]) +async def test_type_schema_enforces_owner_and_roundtrips_instruction(journal, kind): + request = storage_scenarios.request(kind) + await journal.record_intent(request, client_order_id=request.client_order_id, venue="kalshi", account=Account(venue="kalshi")) + record = await journal.typed_order(kind, "kalshi", request.client_order_id) + assert record["user_id"] == "owner-a" and record["request"]["quantity"] == "10" + assert record["payload_version"] == 1 and record["state"] == "planned" + async with journal._db.execute("SELECT is_nullable FROM information_schema.columns WHERE table_schema=? AND table_name=? AND column_name='user_id'", (journal.schema, kind + "_orders")) as cursor: + assert (await cursor.fetchone())["is_nullable"] == "NO" + import psycopg + with pytest.raises(psycopg.errors.CheckViolation): + await journal._db.execute(f"UPDATE {kind}_orders SET user_id=' '") + assert (await journal.typed_order(kind, "kalshi", request.client_order_id))["user_id"] == "owner-a" + + +# These scenarios use the same scripted venue, requests and assertions as +# SQLite, substituting only EngineConfig's persistence selection. +PORTABLE_LIFECYCLE = [ + getattr(lifecycle_scenarios, name) for name in [ + "test_retry_same_stop_returns_original_parent_even_after_restart", + "test_concurrent_retries_create_one_parent_and_conflicts_do_not_send", + "test_ordinary_retry_and_lost_response_never_send_twice", + "test_an_unresolved_retry_stays_pending_without_another_send", + "test_trigger_and_child_plan_are_committed_before_adapter_call", + "test_lost_child_response_stays_triggered_and_is_adopted_after_restart", + "test_cancel_and_trigger_race_leaves_no_working_child", + "test_cancel_acknowledgement_with_nonterminal_result_stays_pending", + "test_order_history_pages_are_exhausted_before_adoption", + "test_cancel_a_planned_child_never_dispatches_it", + "test_expired_planned_stop_limit_does_not_send_after_restart", + "test_cancellation_of_uncertain_child_survives_restart", + "test_a_fill_that_wins_the_cancel_race_closes_the_parent", + "test_risk_refusal_is_durable_on_retry", + "test_sell_stop_market_notional_limit_counts_the_no_price", + "test_unexpected_decoder_error_after_send_preserves_uncertain_stop", + ] +] + + +@pytest.fixture +def postgres_engine_config(database, monkeypatch): + dsn, schema = database + def configured(**kwargs): + return EngineConfig(**{**kwargs, "journal_path": dsn, "storage_backend": "postgresql", "storage_schema": schema}) + monkeypatch.setattr(lifecycle_scenarios, "EngineConfig", configured) + return configured + + +@pytest.mark.parametrize("scenario", PORTABLE_LIFECYCLE, ids=lambda fn: fn.__name__) +async def test_shared_stop_lifecycle(scenario, postgres_engine_config, tmp_path): + await scenario(tmp_path) + + +@pytest.fixture +async def setup(postgres_engine_config): + from synpath.engine.paper import PaperVenue, quadratic_fee + clock = order_scenarios.Clock() + paper = PaperVenue(venue="kalshi", fees=quadratic_fee(), cash=D("10000"), clock=clock) + engine = Engine({"kalshi": paper}, postgres_engine_config(require_lease=True, managed_tick_s=0.01), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, max_orders_per_minute=None, + closing_soon_s=None, max_open_orders=None), + accounts={"kalshi": order_scenarios.ACCOUNT}, clock=clock) + paper.subscribe(engine.on_fill) + paper.subscribe_orders(engine.on_order) + await engine.start() + try: + yield engine, paper, clock + finally: + await engine.stop() + + +PAPER_SCENARIOS = [getattr(order_scenarios.TestStops(), name) for name in vars(order_scenarios.TestStops) + if name.startswith("test_") and name != "test_the_old_stop_params_are_refused"] + [ + lifecycle_scenarios.test_day_retry_keeps_the_original_expiry, + lifecycle_scenarios.test_expiry_wins_when_price_arrives_before_the_timer, + lifecycle_scenarios.test_stop_market_uses_deep_available_liquidity_and_reports_execution_average, + lifecycle_scenarios.test_registered_market_precision_controls_the_persisted_price_bound, + lifecycle_scenarios.test_crossed_book_and_replayed_trade_do_not_trigger, +] + + +@pytest.mark.parametrize("scenario", PAPER_SCENARIOS, ids=lambda fn: fn.__name__) +async def test_shared_paper_execution(scenario, setup): + await scenario(setup) + + +async def test_atomic_child_plan_rolls_back(setup, monkeypatch): + await lifecycle_scenarios.test_atomic_plan_rolls_back_both_parent_and_child(setup, monkeypatch) + + +@pytest.mark.parametrize("boundary", ["before_send", "after_acceptance", "after_response"]) +async def test_hard_crash_recovery(database, tmp_path, boundary): + dsn, schema = database + venue_path = tmp_path / "durable-fake.json" + script = f"DSN={dsn!r}\nSCHEMA={schema!r}\nVENUE={str(venue_path)!r}\nBOUNDARY={boundary!r}\n" + ''' +import asyncio, os +import test_stop_lifecycle as scenarios +from synpath.engine import EngineConfig +original_config = EngineConfig +def configured(**kwargs): + return original_config(**{**kwargs, 'journal_path': DSN, 'storage_backend': 'postgresql', 'storage_schema': SCHEMA}) +scenarios.EngineConfig = configured +async def crash(): + engine, venue = await scenarios.scripted_engine('unused', scenarios.DurableFakeVenue(VENUE)) + await engine.submit(scenarios.stop_request()) + if BOUNDARY == 'before_send': + original = engine.journal.mark_intent + async def exit_before_send(*args, **kwargs): + if args[1] == 'sending': os._exit(77) + return await original(*args, **kwargs) + engine.journal.mark_intent = exit_before_send + elif BOUNDARY == 'after_acceptance': + original = venue.create_order + async def exit_after_acceptance(request): + await original(request) + os._exit(77) + venue.create_order = exit_after_acceptance + else: + original = engine.journal.upsert_order + async def exit_after_response(order, **kwargs): + if order.held_by.value == 'venue': os._exit(77) + return await original(order, **kwargs) + engine.journal.upsert_order = exit_after_response + await scenarios.cross(engine) +asyncio.run(crash()) +''' + environment = {**os.environ, "PYTHONPATH": str(Path(__file__).parent) + os.pathsep + os.environ.get("PYTHONPATH", "")} + completed = await asyncio.to_thread(subprocess.run, [sys.executable, "-c", script], env=environment, + timeout=20, capture_output=True, text=True) + assert completed.returncode == 77, completed.stderr + async with PostgresJournal(dsn, schema=schema) as store: + await store._db.execute("UPDATE leases SET expires_ts=0") + venue = lifecycle_scenarios.DurableFakeVenue(venue_path) + engine, _ = await lifecycle_scenarios.scripted_engine(dsn, venue, storage_backend="postgresql", storage_schema=schema) + try: + parent = engine.managed_order_controller.live()[0] + assert len(parent.children) == 1 and parent.pending_child is None + for _ in range(2): + await engine.managed_order_controller.on_timer() + assert len(venue.orders) == 1 + assert len(venue.sent) == (1 if boundary == "before_send" else 0) + finally: + await engine.stop() + + +async def test_concurrent_initialization_and_lease_takeover(database): + dsn, schema = database + first, second = PostgresJournal(dsn, schema=schema), PostgresJournal(dsn, schema=schema) + await asyncio.gather(first.open(), second.open()) + try: + results = await asyncio.gather(first.acquire_lease(), second.acquire_lease(), return_exceptions=True) + assert sum(isinstance(result, LeaseLost) for result in results) == 1 + winner, loser = (first, second) if first.lease else (second, first) + await winner._db.execute("UPDATE leases SET expires_ts=0") + assert not await winner.renew_lease(), "an expired owner cannot revive itself" + await loser.acquire_lease() + await winner.release_lease() + assert await loser.holds_lease() and not await winner.holds_lease() + finally: + await first.close() + await second.close() + + +async def test_server_clock_and_no_dispatch_after_lease_loss(database, monkeypatch): + engine, venue = await lifecycle_scenarios.scripted_engine(database[0], storage_backend="postgresql", storage_schema=database[1]) + try: + import synpath.engine.journal as module + monkeypatch.setattr(module, "now_ms", lambda: 1) + assert await engine.journal.renew_lease() and await engine.journal.holds_lease() + assert engine.journal.lease.expires_ts > 1_000_000_000_000 + await engine.journal._db.execute("UPDATE leases SET owner='replacement'") + with pytest.raises(LeaseLost): + await engine.submit(lifecycle_scenarios.stop_request()) + assert venue.sent == [] + finally: + await engine.stop() + + +async def test_task_cancellation_rolls_back_and_unblocks_reader(journal, monkeypatch): + original = journal._order_records.write_request + entered = asyncio.Event() + async def pause(*args, **kwargs): + await original(*args, **kwargs) + entered.set() + await asyncio.Event().wait() + monkeypatch.setattr(journal._order_records, "write_request", pause) + writer = asyncio.create_task(journal.record_intent(storage_scenarios.request(), client_order_id="request-a", venue="kalshi", account=Account(venue="kalshi"))) + await asyncio.wait_for(entered.wait(), 2) + reader = asyncio.create_task(journal.typed_order("limit", "kalshi", "request-a")) + await asyncio.sleep(0.01) + assert not reader.done(), "same-session readers must not see uncommitted instruction state" + writer.cancel() + with pytest.raises(asyncio.CancelledError): + await writer + assert await asyncio.wait_for(reader, 2) is None + assert await journal.intent("request-a") is None + assert await journal.append("healthy") > 0 + + +async def test_reservations_are_atomic_across_connections(database): + first = PostgresJournal(database[0], schema=database[1], user_id="owner-a") + second = PostgresJournal(database[0], schema=database[1], user_id="owner-a") + await asyncio.gather(first.open(), second.open()) + req = storage_scenarios.request() + try: + await asyncio.gather(*(store.record_submission(req, req, venue="kalshi", account=Account(venue="kalshi")) for store in (first, second))) + assert len([event async for event in first.replay(kinds=["submission.reserved"])]) == 1 + conflict = req.model_copy(update={"quantity": D("11")}) + with pytest.raises(BadRequest, match="different durable"): + await second.record_submission(conflict, conflict, venue="kalshi", account=Account(venue="kalshi")) + assert (await first.submission(req.client_order_id))["effective_request"]["quantity"] == "10" + finally: + await first.close() + await second.close() + + +@pytest.mark.parametrize("version_key", ["schema_version", "order_records_version", "submissions_version"]) +async def test_reopen_preserves_data_and_rejects_unknown_version(database, version_key): + dsn, schema = database + async with PostgresJournal(dsn, schema=schema) as store: + seq = await store.append("preserved", {"value": 1}) + version = await store.save_config("risk", {"max": "10"}) + await store.set_cursor("cursor", "page-2") + await store.save_bucket({"id": "b", "book": "a", "name": "bucket", "members": [ + {"market_id": "kalshi:KX-A", "flip": True}, {"market_id": "polymarket:1", "enabled": False}]}) + async with PostgresJournal(dsn, schema=schema) as store: + assert await store.last_seq() >= seq + assert await store.config("risk") == (version, {"max": "10"}) + assert await store.cursor("cursor") == "page-2" + assert (await store.bucket("b"))["members"] == [{"market_id": "kalshi:KX-A", "flip": True, "enabled": True}, + {"market_id": "polymarket:1", "flip": False, "enabled": False}] + assert await store.archive_bucket("b") and not await store.archive_bucket("b") + await store._db.execute("UPDATE meta SET value='999' WHERE key=?", (version_key,)) + invalid = PostgresJournal(dsn, schema=schema) + with pytest.raises(ValueError, match="unsupported PostgreSQL journal"): + await invalid.open() + assert not invalid.is_open + + +def test_factory_requires_explicit_dsn_and_hides_credentials(): + with pytest.raises(ValueError, match="explicit connection"): + create_journal(backend="postgresql") + store = create_journal(backend="postgresql", path="postgresql://user:secret@localhost/db", schema="orders") + assert isinstance(store, JournalStore) and store.path == "postgresql:orders" + for schema in ('unsafe; DROP TABLE orders', "a" * 64, "pg_catalog", "information_schema"): + with pytest.raises(ValueError, match="schema name"): + PostgresJournal("postgresql://localhost/db", schema=schema) + + +# Keep adapter-specific payload, asynchronous receipt and response-loss +# assertions intact while running their real engine against PostgreSQL. +import functools +import synpath.engine as engine_module +import test_polymarket_stop_lifecycle as poly_scenarios +import test_polymarket_us_stop_lifecycle as us_scenarios +import test_kalshi_stop_verification as kalshi_scenarios +import examples.verify_kalshi_stop_limit as kalshi_example +import examples.verify_polymarket_us_stop_fill as us_example +from test_polymarket_trading import venue +from test_polymarket_us_trading import ed_key, ed_secret +from test_kalshi_trading import creds, pem, rsa_key + + +@pytest.fixture +def postgres_adapter_config(postgres_engine_config, monkeypatch): + for module in (engine_module, poly_scenarios, kalshi_example, us_example): + monkeypatch.setattr(module, "EngineConfig", postgres_engine_config) + + +def postgres_adapter_scenario(scenario): + @pytest.mark.usefixtures("postgres_adapter_config") + @functools.wraps(scenario) + async def run(*args, **kwargs): + await scenario(*args, **kwargs) + return run + + +test_pg_kalshi_stop_limit = postgres_adapter_scenario(kalshi_scenarios.test_stop_limit_adapter_arms_restores_triggers_and_cleans_only_its_child) +test_pg_kalshi_lost_response = postgres_adapter_scenario(kalshi_scenarios.test_response_lost_still_finds_and_cancels_only_the_accepted_child) +test_pg_polymarket_stop_limit = postgres_adapter_scenario(poly_scenarios.test_waiting_restart_limit_child_direction_and_targeted_cancel) +test_pg_polymarket_stop_market = postgres_adapter_scenario(poly_scenarios.test_stop_market_child_uses_fak_and_remains_pending_until_fill_readback) +test_pg_polymarket_lost_response = postgres_adapter_scenario(poly_scenarios.test_response_lost_never_resends_but_needs_signed_identity_recovery) +test_pg_us_stop_market = postgres_adapter_scenario(us_scenarios.test_fractional_stop_market_waits_crosses_fills_and_closes_only_new_position) +test_pg_us_bounds = postgres_adapter_scenario(us_scenarios.test_subcent_market_grid_still_respects_us_absolute_bounds) + + +async def test_failed_schema_initialization_rolls_back_then_retries(database, monkeypatch): + import psycopg + store = PostgresJournal(database[0], schema=database[1]) + original = store._apply_schema + async def fail(): + await original() + raise RuntimeError("schema checkpoint interrupted") + monkeypatch.setattr(store, "_apply_schema", fail) + with pytest.raises(RuntimeError, match="interrupted"): + await store.open() + assert not store.is_open + async with await psycopg.AsyncConnection.connect(database[0], autocommit=True) as connection: + cursor = await connection.execute("SELECT 1 FROM information_schema.schemata WHERE schema_name=%s", (database[1],)) + assert await cursor.fetchone() is None + monkeypatch.setattr(store, "_apply_schema", original) + async with store: + assert await store.schema_version() == 11 + + +async def test_commit_failure_rolls_back_before_next_writer(journal, monkeypatch): + original = journal._db._execute + failed = False + async def fail_commit(query, *args, **kwargs): + nonlocal failed + if query == "COMMIT" and not failed: + failed = True + raise RuntimeError("commit refused") + return await original(query, *args, **kwargs) + monkeypatch.setattr(journal._db, "_execute", fail_commit) + with pytest.raises(RuntimeError, match="commit refused"): + await journal.record_intent(storage_scenarios.request(), client_order_id="request-a", venue="kalshi", account=Account(venue="kalshi")) + assert await journal.intent("request-a") is None + assert await journal.typed_order("limit", "kalshi", "request-a") is None + assert await asyncio.wait_for(journal.append("next-writer"), 2) > 0 + + +async def test_audit_failure_rolls_back_submission_before_dispatch(database): + import psycopg + engine, venue = await lifecycle_scenarios.scripted_engine(database[0], storage_backend="postgresql", storage_schema=database[1]) + try: + await engine.journal._db.execute("""CREATE FUNCTION refuse_audit() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN IF NEW.kind = 'submission.reserved' THEN RAISE EXCEPTION 'simulated audit failure'; END IF; + RETURN NEW; END $$""") + await engine.journal._db.execute("CREATE TRIGGER refuse_audit BEFORE INSERT ON events FOR EACH ROW EXECUTE FUNCTION refuse_audit()") + with pytest.raises(psycopg.errors.RaiseException, match="simulated audit failure"): + await engine.submit(lifecycle_scenarios.stop_request()) + assert await engine.journal.submission("protect-a") is None + assert engine.managed_order_controller.parents == {} and venue.sent == [] + finally: + await engine.stop() + + +async def test_sidecar_selects_postgres_without_recording_connection_secret(database, tmp_path): + import json + from synpath.server.serve import build + stack = await build(config={"storage_backend": "postgresql", "storage_schema": database[1], "journal": database[0]}, + control=str(tmp_path / "control.db"), home_dir=str(tmp_path / "home"), + adapters={}, streams=False) + try: + assert isinstance(stack.engine.journal, PostgresJournal) + registry = json.loads((tmp_path / "home" / "servers.json").read_text()) + assert registry["127.0.0.1:8000"]["journal"] == "postgresql:" + database[1] + assert database[0] not in json.dumps(registry) + finally: + await stack.engine.stop() + await stack.store.close() + + +async def test_missing_driver_is_explicit_and_does_not_fall_back(monkeypatch): + monkeypatch.setitem(sys.modules, "psycopg", None) + store = PostgresJournal("postgresql://localhost/test") + with pytest.raises(ImportError, match="synpath\\[postgres\\]"): + await store.open() + assert not store.is_open + + +import test_kalshi_stop_fill as kalshi_fill_scenarios +import examples.verify_kalshi_stop_fill as kalshi_fill_example + +@pytest.fixture +def postgres_kalshi_fill_config(postgres_engine_config, monkeypatch): + monkeypatch.setattr(kalshi_fill_example, "EngineConfig", postgres_engine_config) + +@pytest.mark.usefixtures("postgres_kalshi_fill_config") +@functools.wraps(kalshi_fill_scenarios.test_real_price_crossing_fills_once_then_closes_only_test_quantity) +async def test_pg_kalshi_stop_fill(*args, **kwargs): + await kalshi_fill_scenarios.test_real_price_crossing_fills_once_then_closes_only_test_quantity(*args, **kwargs) + + +test_pg_us_already_eligible = postgres_adapter_scenario(us_scenarios.test_already_eligible_stop_uses_next_real_observation) + +@pytest.mark.usefixtures("postgres_kalshi_fill_config") +@functools.wraps(kalshi_fill_scenarios.test_price_wait_renews_lease_before_sending) +async def test_pg_kalshi_fill_lease(*args, **kwargs): + await kalshi_fill_scenarios.test_price_wait_renews_lease_before_sending(*args, **kwargs) diff --git a/tests/test_predict_fun_trading.py b/tests/test_predict_fun_trading.py index f51e8bc..6f79c4c 100644 --- a/tests/test_predict_fun_trading.py +++ b/tests/test_predict_fun_trading.py @@ -136,8 +136,8 @@ def test_a_sell_buys_no_at_the_complement(self): assert order["tokenId"] == INFO.no_token and order["side"] == sig.BUY assert int(order["makerAmount"]) == 6 * 10**18 - def test_reduce_only_sells_the_token_held(self): - order = signed(request(side=Side.SELL, reduce_only=True))[0]["data"]["order"] + def test_selling_yes_sells_the_token_held(self): + order = signed(request(side=Side.SELL, outcome="yes", action="sell"))[0]["data"]["order"] assert order["tokenId"] == INFO.yes_token and order["side"] == sig.SELL def test_market_ioc_and_fok_take_at_once(self): diff --git a/tests/test_predict_fun_ws.py b/tests/test_predict_fun_ws.py index 3c78ec4..b453a07 100644 --- a/tests/test_predict_fun_ws.py +++ b/tests/test_predict_fun_ws.py @@ -23,6 +23,7 @@ NATIVE = BOOK["topic"].split("/")[1] MARKET = f"predict_fun:{NATIVE}" +# The venue's documented example of a wallet event. BASE_EVENT = { "type": "orderAccepted", "orderId": "123456789", "orderHash": "0xabc", "walletAddress": "0xdef", "timestamp": 1736696400000, @@ -30,7 +31,6 @@ "quoteType": "BID", "quantity": "100.000", "quantityFilled": "0.000", "price": "0.620", "value": "62.00", "valueFilled": "0.00", "strategyType": "LIMIT", "categorySlug": "politics"}, } -"""The venue's documented example of a wallet event.""" def event(kind: str, **extra) -> dict: @@ -125,7 +125,7 @@ def test_subscriptions_are_one_topic_each_and_sent_once(self): def test_every_book_message_is_a_snapshot_in_yes_prices(self): stream = market_stream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps(BOOK)) + stream._process_raw_frame_and_emit_events(json.dumps(BOOK)) [book_event] = [e for e in drain(stream) if isinstance(e, BookEvent)] assert book_event.kind == "snapshot" and book_event.market_id == MARKET book = stream.book(MARKET) @@ -135,8 +135,8 @@ def test_every_book_message_is_a_snapshot_in_yes_prices(self): def test_the_ticker_reports_a_new_last_price_once(self): stream = market_stream() asyncio.run(watching(stream, stream.watch_ticker([MARKET]))) - stream._dispatch(json.dumps(BOOK)) - stream._dispatch(json.dumps(BOOK)) + stream._process_raw_frame_and_emit_events(json.dumps(BOOK)) + stream._process_raw_frame_and_emit_events(json.dumps(BOOK)) first, second = [e for e in drain(stream) if isinstance(e, QuoteEvent)] settled = BOOK["data"]["lastOrderSettled"] expected = Decimal(settled["price"]) if settled["outcome"] == "Yes" else 1 - Decimal(settled["price"]) @@ -145,8 +145,8 @@ def test_the_ticker_reports_a_new_last_price_once(self): def test_market_and_trading_status(self): stream = market_stream() - stream._dispatch(json.dumps(WS["predictMarketStatus"])) - stream._dispatch(json.dumps(WS["predictTradingStatus"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["predictMarketStatus"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["predictTradingStatus"])) states = [e.state for e in drain(stream) if isinstance(e, MarketStatusEvent)] assert states == ["open", "open"] @@ -155,7 +155,7 @@ def test_heartbeats_are_echoed_exactly(self): async def run(): stream._ws = FakeSocket() - stream._dispatch(json.dumps(WS["heartbeat"])) + stream._process_raw_frame_and_emit_events(json.dumps(WS["heartbeat"])) await asyncio.sleep(0) return stream._ws.sent @@ -164,14 +164,14 @@ async def run(): def test_a_refused_subscription_is_reported(self): stream = market_stream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps({"type": "R", "requestId": 1, "success": False, + stream._process_raw_frame_and_emit_events(json.dumps({"type": "R", "requestId": 1, "success": False, "error": {"code": "invalid_topic", "message": "unknown topic"}})) assert [e.state for e in drain(stream) if isinstance(e, StreamStatusEvent)] == ["error"] def test_a_reconnect_marks_books_not_ready(self): stream = market_stream() asyncio.run(watching(stream, stream.watch_order_book([MARKET]))) - stream._dispatch(json.dumps(BOOK)) + stream._process_raw_frame_and_emit_events(json.dumps(BOOK)) stream.on_disconnect() assert not stream.book(MARKET).ready @@ -181,13 +181,14 @@ def test_the_jwt_is_the_topic_and_never_reported(self): stream = PredictFunUserStream(api_key="test-key", jwt="secret.jwt.value") sent = asyncio.run(watching(stream, stream.watch_orders())) assert sent[0]["params"] == ["predictWalletEvents/secret.jwt.value"] - stream.status("error", "refused predictWalletEvents/secret.jwt.value") + stream._record_error_and_emit_status("refused predictWalletEvents/secret.jwt.value") assert "secret.jwt.value" not in drain(stream)[-1].detail + assert stream.stats.errors == 1 def test_events_become_orders_and_fills(self): stream = PredictFunUserStream(api_key="test-key", jwt="j") fill = {"executedPriceWei": str(62 * 10**16), "executedSizeWei": str(10**19), "executedValueWei": "0"} - stream._dispatch(json.dumps({"type": "M", "topic": "predictWalletEvents/j", + stream._process_raw_frame_and_emit_events(json.dumps({"type": "M", "topic": "predictWalletEvents/j", "data": event("orderTransactionSuccess", settlementId="s", fill=fill)})) events = drain(stream) assert [type(e) for e in events] == [OrderEvent, FillEvent] @@ -200,7 +201,7 @@ def test_an_expired_jwt_is_renewed_from_its_source(self): async def run(): sent = await watching(stream, stream.watch_orders()) request_id = sent[0]["requestId"] - stream._dispatch(json.dumps({"type": "R", "requestId": request_id, "success": False, + stream._process_raw_frame_and_emit_events(json.dumps({"type": "R", "requestId": request_id, "success": False, "error": {"code": "invalid_credentials"}})) await asyncio.sleep(0) await asyncio.sleep(0) @@ -221,7 +222,7 @@ async def source(*, fresh: bool = False): async def run(): sent = await watching(stream, stream.watch_orders()) - stream._dispatch(json.dumps({"type": "R", "requestId": sent[0]["requestId"], "success": False, + stream._process_raw_frame_and_emit_events(json.dumps({"type": "R", "requestId": sent[0]["requestId"], "success": False, "error": {"code": "invalid_credentials"}})) for _ in range(3): await asyncio.sleep(0) diff --git a/tests/test_rfq_models.py b/tests/test_rfq_models.py new file mode 100644 index 0000000..69169aa --- /dev/null +++ b/tests/test_rfq_models.py @@ -0,0 +1,444 @@ +"""RFQ and combo models, the capability keys, and sizing by amount (P1 of docs/rfq-combo.md). + +No venue is reached. +""" +from __future__ import annotations + +import importlib +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +import synpath +from synpath.base import CAPABILITIES +from synpath.trading import ( + AcceptQuote, Combo, ComboLeg, InvalidOrder, MakerQuote, Precision, QuoteStatus, Rfq, RfqCloseReason, + RfqQuote, RfqStatus, Side, +) +from synpath.trading.instruments import VENUE_RULES, venue_precision +from synpath.trading.money import amount_for_contracts, contracts_for_amount, outcome_price +from synpath.trading.types import QUOTE_TERMINAL_STATUSES + +D = Decimal + +RFQ_KEYS = ("rfq_taker", "rfq_quoter", "combos") + +TRADING_ADAPTERS = { + "kalshi": ("synpath.trading.kalshi", "KalshiTrading"), + "polymarket": ("synpath.trading.polymarket", "PolymarketTrading"), + "polymarket_us": ("synpath.trading.polymarket_us", "PolymarketUSTrading"), + "polymarket_us_exchange": ("synpath.trading.polymarket_us_exchange", "PolymarketUSExchangeTrading"), + "opinion": ("synpath.trading.opinion", "OpinionTrading"), + "hyperliquid": ("synpath.trading.hyperliquid", "HyperliquidTrading"), + "predict_fun": ("synpath.trading.predict_fun", "PredictFunTrading"), + "limitless": ("synpath.trading.limitless", "LimitlessTrading"), + "sx_bet": ("synpath.trading.sx_bet", "SxBetTrading"), + "paper": ("synpath.engine.paper", "PaperVenue"), +} + +PLANNED = { + # docs/rfq-combo.md §2: what each venue may claim once its phase lands. + "kalshi": {"rfq_taker", "rfq_quoter", "combos"}, + "polymarket": {"rfq_taker", "rfq_quoter", "combos"}, + "polymarket_us": {"rfq_taker", "rfq_quoter", "combos"}, + "sx_bet": {"rfq_quoter", "combos"}, +} + +LANDED = { + # Phases done (docs/rfq-combo.md §8). + "polymarket_us": {"rfq_taker", "rfq_quoter", "combos"}, # P2 + "kalshi": {"rfq_taker", "rfq_quoter", "combos"}, # P6 + "polymarket": {"rfq_taker", "rfq_quoter", "combos"}, # P7 + "sx_bet": {"rfq_quoter", "combos"}, # P8 +} + + +def adapter(name: str) -> type: + module, cls = TRADING_ADAPTERS[name] + return getattr(importlib.import_module(module), cls) + + +def leg(market: str, outcome: str = "yes") -> ComboLeg: + return ComboLeg(market_id=market, outcome=outcome) + + +# -- capability keys ----------------------------------------------------------------- + + +class TestCapabilities: + def test_keys_are_known_capabilities(self): + for key in RFQ_KEYS: + assert key in CAPABILITIES + assert "rfq" in CAPABILITIES # the older key stays for one release + + @pytest.mark.parametrize("name", sorted(TRADING_ADAPTERS)) + def test_every_trading_adapter_declares_them(self, name): + cls = adapter(name) + declared = cls.__dict__["has"] + for key in RFQ_KEYS: + assert key in declared, (name, key) + assert cls.has[key] in (True, False, "partial") + + @pytest.mark.parametrize("name", sorted(TRADING_ADAPTERS)) + def test_true_only_where_the_plan_supports_it(self, name): + cls = adapter(name) + for key in RFQ_KEYS: + if cls.has[key] is not False: + assert key in PLANNED.get(name, set()), (name, key) + + @pytest.mark.parametrize("name", sorted(TRADING_ADAPTERS)) + def test_nothing_claimed_before_its_phase(self, name): + """A key goes True only when the adapter answering it has landed: + Polymarket US in P2, Kalshi in P6, Polymarket global in P7; the others stay False until their + phase lands.""" + cls = adapter(name) + for key in RFQ_KEYS: + assert (cls.has[key] is True) == (key in LANDED.get(name, set())), (name, key) + + def test_out_of_scope_venues_never_claim(self): + for name in ("limitless", "opinion", "hyperliquid", "predict_fun", "polymarket_us_exchange", "paper"): + assert all(adapter(name).has[key] is False for key in RFQ_KEYS), name + + def test_read_adapters_answer_false(self): + for venue in synpath.exchanges.values(): + for key in RFQ_KEYS: + assert venue.has[key] is False, (venue.id, key) + + +# -- combos -------------------------------------------------------------------------- + + +class TestCombo: + def test_a_combo_has_its_own_market_id_and_legs(self): + combo = Combo( + market_id="kalshi:KXMVECROSSCATEGORY-R-ABC", venue="kalshi", collection_id="KXMVECROSSCATEGORY-R", + legs=[leg("kalshi:KXA-1"), ComboLeg(market_id="kalshi:KXB-2", outcome="no", event_id="KXB")], + info={"raw": 1}, + ) + assert [l.outcome for l in combo.legs] == ["yes", "no"] + assert combo.legs[1].event_id == "KXB" + assert combo.info == {"raw": 1} + assert combo.status is None + + def test_needs_two_legs(self): + with pytest.raises(ValidationError, match="at least two legs"): + Combo(market_id="polymarket_us:caoc-1", venue="polymarket_us", legs=[leg("polymarket_us:a")]) + + def test_each_market_once(self): + with pytest.raises(ValidationError, match="each market once"): + Combo(market_id="polymarket_us:caoc-1", venue="polymarket_us", + legs=[leg("polymarket_us:a"), leg("polymarket_us:a", "no")]) + + def test_leg_outcome_is_yes_or_no(self): + with pytest.raises(ValidationError): + ComboLeg(market_id="kalshi:X", outcome="maybe") + + def test_status_vocabulary(self): + Combo(market_id="c", venue="kalshi", legs=[leg("a"), leg("b")], status="settled") + with pytest.raises(ValidationError): + Combo(market_id="c", venue="kalshi", legs=[leg("a"), leg("b")], status="active") + + def test_extra_fields_are_refused(self): + with pytest.raises(ValidationError): + ComboLeg(market_id="a", outcome="yes", side="buy") + + +# -- RFQ ----------------------------------------------------------------------------- + + +class TestRfq: + def test_on_a_market_by_contracts(self): + rfq = Rfq(id="r1", venue="kalshi", market_id="kalshi:KXA-1", quantity=D("10"), created_at=1, expires_at=2) + assert rfq.status == RfqStatus.OPEN + assert rfq.instrument_id == "kalshi:KXA-1" + assert rfq.amount is None and rfq.outcome is None and rfq.closed_reason is None + + def test_on_a_combo_by_amount(self): + combo = Combo(market_id="polymarket_us:caoc-1", venue="polymarket_us", legs=[leg("a"), leg("b")]) + rfq = Rfq(id="r2", venue="polymarket_us", combo_id="polymarket_us:caoc-1", combo=combo, amount=D("25")) + assert rfq.instrument_id == "polymarket_us:caoc-1" + assert rfq.quantity is None + + def test_market_or_combo_exactly_one(self): + with pytest.raises(ValidationError, match="exactly one"): + Rfq(id="r", venue="kalshi", quantity=D("1")) + with pytest.raises(ValidationError, match="exactly one"): + Rfq(id="r", venue="kalshi", market_id="a", combo_id="b", quantity=D("1")) + + def test_combo_must_match_combo_id(self): + combo = Combo(market_id="x", venue="kalshi", legs=[leg("a"), leg("b")]) + with pytest.raises(ValidationError, match="is not combo_id"): + Rfq(id="r", venue="kalshi", combo_id="y", combo=combo, quantity=D("1")) + + def test_contracts_or_amount_exactly_one(self): + with pytest.raises(ValidationError, match="not both"): + Rfq(id="r", venue="kalshi", market_id="a", quantity=D("1"), amount=D("1")) + with pytest.raises(ValidationError, match="give a size"): + Rfq(id="r", venue="kalshi", market_id="a") + + def test_an_incoming_rfq_may_arrive_without_a_size(self): + rfq = Rfq(id="r", venue="kalshi", market_id="a", mine=False) + assert rfq.quantity is None and rfq.amount is None + + def test_sizes_are_positive(self): + for field in ("quantity", "amount"): + with pytest.raises(ValidationError, match="positive"): + Rfq(id="r", venue="kalshi", market_id="a", **{field: D("0")}) + + def test_one_sided_request(self): + rfq = Rfq(id="r", venue="polymarket", combo_id="polymarket:c", outcome="yes", action="buy", amount=D("5")) + assert (rfq.outcome, rfq.action) == ("yes", "buy") + with pytest.raises(ValidationError, match="needs the outcome"): + Rfq(id="r", venue="polymarket", combo_id="c", action="sell", quantity=D("5")) + + def test_closed_with_a_reason(self): + rfq = Rfq(id="r", venue="kalshi", market_id="a", quantity=D("1"), + status="closed", closed_reason="executed") + assert rfq.status == RfqStatus.CLOSED and rfq.closed_reason == RfqCloseReason.EXECUTED + with pytest.raises(ValidationError, match="closed RFQ"): + Rfq(id="r", venue="kalshi", market_id="a", quantity=D("1"), closed_reason="expired") + + def test_round_trips_through_json(self): + rfq = Rfq(id="r", venue="kalshi", market_id="a", amount=D("12.50"), info={"id": "r"}) + again = Rfq.model_validate_json(rfq.model_dump_json()) + assert again == rfq and again.amount == D("12.50") + + def test_amount_is_not_the_deprecated_quantity_alias(self): + rfq = Rfq(id="r", venue="kalshi", market_id="a", amount=D("3")) + assert rfq.amount == D("3") and rfq.quantity is None + + +# -- quotes -------------------------------------------------------------------------- + + +def quote(**kw) -> RfqQuote: + base = dict(id="q1", rfq_id="r1", venue="kalshi", bid=D("0.40"), ask=D("0.45"), + bid_quantity=D("10"), ask_quantity=D("8")) + return RfqQuote(**{**base, **kw}) + + +class TestQuote: + def test_defaults(self): + q = quote() + assert q.status == QuoteStatus.OPEN and not q.is_terminal and not q.mine + assert q.accepted_side is None and q.taker_order_id is None and q.maker_order_id is None + + def test_prices_for_each_outcome_and_action(self): + q = quote() + assert q.price_for("yes", "buy") == D("0.45") + assert q.price_for("no", "sell") == D("0.45") + assert q.price_for("yes", "sell") == D("0.40") + assert q.price_for("no", "buy") == D("0.40") + assert q.quantity_for("yes", "buy") == D("8") + assert q.quantity_for("no", "buy") == D("10") + + def test_a_declined_side_is_none(self): + q = quote(bid=None, bid_quantity=None) + assert q.price_for("yes", "sell") is None and q.price_for("yes", "buy") == D("0.45") + with pytest.raises(ValidationError, match="bid, an ask or both"): + quote(bid=None, ask=None) + + def test_prices_are_yes_prices_inside_zero_one(self): + for bad in (D("0"), D("1"), D("1.2"), D("-0.1")): + with pytest.raises(ValidationError, match="inside"): + quote(ask=bad) + + def test_bid_not_above_ask(self): + quote(bid=D("0.45"), ask=D("0.45")) + with pytest.raises(ValidationError, match="above ask"): + quote(bid=D("0.50"), ask=D("0.45")) + + def test_sizes_positive(self): + with pytest.raises(ValidationError, match="positive"): + quote(ask_quantity=D("0")) + + @pytest.mark.parametrize("status", list(QuoteStatus)) + def test_terminal_statuses(self, status): + q = quote(status=status) + assert q.is_terminal == (status in {"executed", "canceled", "expired", "failed"}) + + def test_terminal_set_matches_the_lifecycle(self): + assert QUOTE_TERMINAL_STATUSES == {QuoteStatus.EXECUTED, QuoteStatus.CANCELED, + QuoteStatus.EXPIRED, QuoteStatus.FAILED} + assert [s.value for s in QuoteStatus] == [ + "open", "accepted", "confirmed", "executed", "canceled", "expired", "failed"] + + def test_executed_carries_both_orders(self): + q = quote(status="executed", accepted_side="buy", taker_order_id="o1", maker_order_id="o2", + confirm_by=10, execute_by=20, info={"raw": True}) + assert q.accepted_side == Side.BUY and q.info == {"raw": True} + + def test_round_trip(self): + q = quote(status="confirmed", mine=True) + assert RfqQuote.model_validate_json(q.model_dump_json()) == q + + def test_not_the_market_data_quote(self): + assert synpath.RfqQuote is RfqQuote and synpath.Quote is not RfqQuote + + +# -- accept and maker quote ------------------------------------------------------------ + + +class TestAcceptQuote: + def test_side_from_outcome_and_action(self): + assert AcceptQuote(quote_id="q", outcome="yes", action="buy").side == Side.BUY + assert AcceptQuote(quote_id="q", outcome="no", action="buy").side == Side.SELL + assert AcceptQuote(quote_id="q", outcome="yes", action="sell").side == Side.SELL + assert AcceptQuote(quote_id="q", outcome="no", action="sell").side == Side.BUY + + def test_outcome_and_action_required(self): + with pytest.raises(ValidationError): + AcceptQuote(quote_id="q", outcome="yes") + + def test_worst_price_refuses_worse(self): + buy = AcceptQuote(quote_id="q", outcome="yes", action="buy", worst_price=D("0.45")) + assert not buy.refuses(D("0.45")) and not buy.refuses(D("0.44")) and buy.refuses(D("0.46")) + # Buying NO is selling YES: the worst YES price is the lowest. + no = AcceptQuote(quote_id="q", outcome="no", action="buy", worst_price=D("0.40")) + assert not no.refuses(D("0.41")) and no.refuses(D("0.39")) + assert not AcceptQuote(quote_id="q", outcome="yes", action="buy").refuses(D("0.99")) + + def test_worst_price_is_a_yes_price(self): + with pytest.raises(ValidationError, match="inside"): + AcceptQuote(quote_id="q", outcome="yes", action="buy", worst_price=D("1")) + + def test_at_most_one_partial_size(self): + AcceptQuote(quote_id="q", outcome="yes", action="buy", quantity=D("2")) + with pytest.raises(ValidationError, match="not both"): + AcceptQuote(quote_id="q", outcome="yes", action="buy", quantity=D("2"), amount=D("1")) + + def test_carries_client_id_and_attribution(self): + a = AcceptQuote(quote_id="q", rfq_id="r", outcome="yes", action="buy", client_order_id="c1", + book="desk", trader="yuki", tags={"k": "v"}) + assert a.client_order_id == "c1" and a.book == "desk" and a.tags == {"k": "v"} + + +class TestMakerQuote: + def test_two_sided(self): + m = MakerQuote(rfq_id="r", bid=D("0.40"), ask=D("0.45"), quantity=D("10"), expires_at=5, client_order_id="c") + assert m.rest_remainder is False + + def test_one_side_is_enough(self): + MakerQuote(rfq_id="r", ask=D("0.45")) + with pytest.raises(ValidationError, match="bid, an ask or both"): + MakerQuote(rfq_id="r") + + def test_bid_below_ask(self): + with pytest.raises(ValidationError, match="below ask"): + MakerQuote(rfq_id="r", bid=D("0.45"), ask=D("0.45")) + + def test_prices_and_size_checked(self): + with pytest.raises(ValidationError, match="inside"): + MakerQuote(rfq_id="r", ask=D("0")) + with pytest.raises(ValidationError, match="positive"): + MakerQuote(rfq_id="r", ask=D("0.5"), quantity=D("-1")) + + +# -- sizing by amount ---------------------------------------------------------------- + + +class TestSizing: + def test_outcome_price(self): + assert outcome_price(D("0.45"), "yes") == D("0.45") + assert outcome_price(D("0.45"), "no") == D("0.55") + with pytest.raises(InvalidOrder): + outcome_price(D("0.45"), "maybe") + + def test_rounds_down_to_the_kalshi_step(self): + kalshi = venue_precision("kalshi") + assert contracts_for_amount(D("10"), D("0.30"), kalshi) == D("33.33") + assert contracts_for_amount(D("10"), D("0.33"), kalshi) == D("30.30") + + def test_whole_contracts_on_polymarket_us(self): + assert contracts_for_amount(D("10"), D("0.30"), venue_precision("polymarket_us")) == D("33") + assert contracts_for_amount(D("10"), D("0.334"), venue_precision("polymarket_us")) == D("29") + + def test_whole_contracts_flag_wins_over_a_finer_step(self): + p = Precision(tick=D("0.01"), min_quantity=D("1"), quantity_step=D("0.01"), whole_contracts=True) + assert contracts_for_amount(D("10"), D("0.30"), p) == D("33") + + def test_no_step_uses_six_decimals(self): + p = Precision(tick=D("0.01"), min_quantity=D("0.000001"), quantity_step=None) + assert contracts_for_amount(D("1"), D("0.3"), p) == D("3.333333") + + @pytest.mark.parametrize("venue", sorted(VENUE_RULES)) + @pytest.mark.parametrize("amount,price", [(D("100"), D("0.37")), (D("17.77"), D("0.613")), (D("55"), D("0.0125"))]) + def test_never_spends_more_than_the_amount(self, venue, amount, price): + precision = venue_precision(venue) + contracts = contracts_for_amount(amount, price, precision) + assert amount_for_contracts(contracts, price) <= amount + step = D("1") if precision.whole_contracts else precision.quantity_step + # One more step would overspend: the rounding is down, not truncated early. + assert amount_for_contracts(contracts + step, price) > amount + assert (contracts / step) % 1 == 0 + + def test_below_the_minimum_quantity_is_refused(self): + with pytest.raises(InvalidOrder, match="below the minimum 5"): + contracts_for_amount(D("2"), D("0.50"), venue_precision("polymarket")) + assert contracts_for_amount(D("2.5"), D("0.50"), venue_precision("polymarket")) == D("5") + + def test_rounding_to_zero_is_refused(self): + with pytest.raises(InvalidOrder, match="below the minimum"): + contracts_for_amount(D("0.5"), D("0.90"), venue_precision("polymarket_us")) + + def test_below_the_minimum_notional_is_refused(self): + opinion = venue_precision("opinion") + with pytest.raises(InvalidOrder, match="minimum order of 5"): + contracts_for_amount(D("4.99"), D("0.50"), opinion) + assert contracts_for_amount(D("5"), D("0.50"), opinion) == D("10") + # Rounding down can take an amount at the minimum below it. + with pytest.raises(InvalidOrder, match="minimum order"): + contracts_for_amount(D("10.00"), D("0.98"), venue_precision("hyperliquid")) + + def test_worst_price_caps_a_buy(self): + kalshi = venue_precision("kalshi") + assert contracts_for_amount(D("10"), D("0.40"), kalshi, worst_price=D("0.40")) == D("25") + assert contracts_for_amount(D("10"), D("0.39"), kalshi, worst_price=D("0.40")) == D("25.64") + with pytest.raises(InvalidOrder, match="worse than the worst price"): + contracts_for_amount(D("10"), D("0.41"), kalshi, worst_price=D("0.40")) + + def test_worst_price_caps_a_sell(self): + kalshi = venue_precision("kalshi") + assert contracts_for_amount(D("10"), D("0.60"), kalshi, action="sell", worst_price=D("0.55")) == D("16.66") + with pytest.raises(InvalidOrder, match="worse"): + contracts_for_amount(D("10"), D("0.50"), kalshi, action="sell", worst_price=D("0.55")) + + def test_spend_at_the_worst_price_stays_within_the_amount(self): + kalshi = venue_precision("kalshi") + worst = D("0.47") + for price in (D("0.40"), D("0.45"), D("0.47")): + contracts = contracts_for_amount(D("20"), price, kalshi, worst_price=worst) + assert amount_for_contracts(contracts, price) <= D("20") + assert price <= worst + + def test_no_side_priced_by_its_complement(self): + # Buying NO at a YES price of 0.70 costs 0.30 a contract. + contracts = contracts_for_amount(D("9"), outcome_price(D("0.70"), "no"), venue_precision("kalshi")) + assert contracts == D("30") + + def test_bad_inputs(self): + kalshi = venue_precision("kalshi") + for amount, price in ((D("0"), D("0.5")), (D("-1"), D("0.5")), (D("1"), D("0")), (D("1"), D("1"))): + with pytest.raises(InvalidOrder): + contracts_for_amount(amount, price, kalshi) + with pytest.raises(InvalidOrder, match="action"): + contracts_for_amount(D("1"), D("0.5"), kalshi, action="hold") + + def test_floats_and_strings_are_read_exactly(self): + assert contracts_for_amount(10, 0.3, venue_precision("kalshi")) == D("33.33") + assert contracts_for_amount("10", "0.3", venue_precision("kalshi")) == D("33.33") + + def test_inverse(self): + assert amount_for_contracts(D("33.33"), D("0.30")) == D("9.9990") + assert amount_for_contracts(D("0"), D("0.30")) == D("0") + with pytest.raises(InvalidOrder): + amount_for_contracts(D("-1"), D("0.30")) + + def test_venue_precision(self): + p = venue_precision("polymarket_us") + assert p.whole_contracts and p.min_quantity == D("1") and p.tick == D("0.001") + assert venue_precision("kalshi", tick=D("0.001")).tick == D("0.001") + assert venue_precision("opinion").min_notional == D("5") + with pytest.raises(KeyError): + venue_precision("nowhere") diff --git a/tests/test_risk_duplicates.py b/tests/test_risk_duplicates.py index 28800d4..86e9fda 100644 --- a/tests/test_risk_duplicates.py +++ b/tests/test_risk_duplicates.py @@ -28,15 +28,15 @@ def risk() -> RiskEngine: def test_the_same_order_from_outside_is_refused_within_the_window(): - decision = risk().check(req(), venue="kalshi", account=ACCOUNT) + decision = risk().evaluate_order_risk(req(), venue="kalshi", account=ACCOUNT) assert not decision.ok and decision.rule == "duplicate" def test_an_engine_child_that_looks_the_same_is_not_a_duplicate(): - decision = risk().check(req(tags={"parent": "mo-2"}), venue="kalshi", account=ACCOUNT) + decision = risk().evaluate_order_risk(req(tags={"parent": "mo-2"}), venue="kalshi", account=ACCOUNT) assert decision.ok, decision.message def test_a_caller_who_names_the_client_order_id_is_not_clicking_twice(): - decision = risk().check(req(client_order_id="mine-2"), venue="kalshi", account=ACCOUNT, deliberate=True) + decision = risk().evaluate_order_risk(req(client_order_id="mine-2"), venue="kalshi", account=ACCOUNT, caller_supplied_order_id=True) assert decision.ok, decision.message diff --git a/tests/test_routed_audit_pagination.py b/tests/test_routed_audit_pagination.py new file mode 100644 index 0000000..5461d84 --- /dev/null +++ b/tests/test_routed_audit_pagination.py @@ -0,0 +1,195 @@ +"""Routed account isolation over sockets and indexed membership pagination.""" +from __future__ import annotations + +import base64 +import json + +import pytest +from fastapi.testclient import TestClient + +from synpath.engine.orders.routed import RoutedLimit +from synpath.engine.storage import create_journal +from synpath.server.store import ControlStore +from synpath.server.trading import create_trading_app +from synpath.trading.types import Account, Order +from test_owner_migrations import configuration, config # noqa: F401 +from test_postgres_storage import database # noqa: F401 +from test_routed_limit import build, request, ACCOUNT_K, ACCOUNT_P, K, P + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.parametrize("full_scope", [False, True], ids=["partial", "full"]) +async def test_socket_replay_and_live_filter_parents_but_keep_concrete_children(tmp_path, full_scope): + engine, _, _, _, bucket = await build(tmp_path) + store = await ControlStore(str(tmp_path / "socket-control.db")).open() + try: + parent = await engine.submit(request(bucket)) + owner, _ = await store.bootstrap("owner") + viewer = await store.create_user("viewer") + await store.acting_as(f"user:{owner.id}", "socket account grants") + await store.grant(viewer.id, ACCOUNT_K.key, "view") + if full_scope: + await store.grant(viewer.id, ACCOUNT_P.key, "view") + key = await store.issue_key(viewer.id) + children = [Order(id=f"probe-{venue}", venue=venue, account=account, + market_id=market, side="buy", type="limit", status="open", + time_in_force="gtc", quantity="1", price="0.4", parent_id=parent.id) + for venue, account, market in [("kalshi", ACCOUNT_K, K), ("polymarket", ACCOUNT_P, P)]] + + async def publish_phase(phase): + await engine.bus.publish("managed.security_probe", {**parent.model_dump(mode="json"), "probe": phase}, key=parent.id) + for child in children: + await engine.bus.publish("order.security_probe", {**child.model_dump(mode="json"), "probe": phase}, key=f"{child.venue}:{child.id}") + await engine.bus.publish("verification.barrier", {"probe": phase}) + + since = await engine.journal.last_seq() + await publish_phase("replay") + with TestClient(create_trading_app(engine, store)) as client: + kinds = "managed.security_probe,order.security_probe,verification.barrier" + with client.websocket_connect(f"/ws/events?key={key.secret}&since={since}&kinds={kinds}") as socket: + for phase in ("replay", "live"): + if phase == "live": + client.portal.call(publish_phase, phase) + received = [] + while True: + event = json.loads(socket.receive_text()) + assert event["payload"]["probe"] == phase + if event["kind"] == "verification.barrier": + break + received.append(event) + expected = {"probe-kalshi", "probe-polymarket", parent.id} if full_scope else {"probe-kalshi"} + assert {event["payload"]["id"] for event in received} == expected + assert len(received) == len(expected) + assert all(event["seq"] > since for event in received) + finally: + await store.close() + await engine.stop() + + +async def test_membership_filters_page_live_and_finished_parents_with_indexed_queries(config, tmp_path, monkeypatch): + engine, _, _, _, bucket = await build(tmp_path) + try: + accepted = await engine.submit(request(bucket)) + original = engine.managed_order_controller.get(accepted.id).snapshot() + async with create_journal(**config, user_id=engine.config.user_id) as store: + parents = [] + for index in range(8): + snapshot = {**original, "id": f"membership-{index}", "created_ms": 1000, + "state": "working" if index % 2 == 0 else "done"} + snapshot["request"] = {**original["request"], "client_order_id": f"membership-client-{index}"} + parent = RoutedLimit.from_snapshot(snapshot) + await store.save_managed(snapshot, order=parent.as_order()) + parents.append(parent.as_order()) + concrete = [] + for venue, account, market in [("kalshi", ACCOUNT_K, K), ("polymarket", ACCOUNT_P, P)]: + for terminal in (False, True): + order = Order(user_id=engine.config.user_id, id=f"concrete-{terminal}", venue=venue, + account=account, market_id=market, side="buy", type="limit", time_in_force="gtc", + status="closed" if terminal else "open", quantity="1", price="0.4", created_at=1000) + await store.upsert_order(order) + concrete.append(order) + outsider = Order(user_id=engine.config.user_id, id="outside", venue="other", account=Account(venue="other"), + market_id="other:market", side="buy", type="limit", time_in_force="gtc", status="open", quantity="1", created_at=1000) + await store.upsert_order(outsider) + captured = [] + executed_queries = [] + execute = store._db.execute + + def record_query(query, params=()): + executed_queries.append(query) + if query.startswith("SELECT payload") and "parent_execution_accounts" in query: + captured.append((query, params)) + return execute(query, params) + + monkeypatch.setattr(store._db, "execute", record_query) + for venue in ("kalshi", "polymarket"): + for open_only in (False, True): + for page_size in (1, 3, 20): + before_count = len(executed_queries) + first_page = await store.orders_page(venue=venue, open_only=open_only, limit=page_size) + queries = executed_queries[before_count:] + assert len(queries) == 1, queries + assert queries[0].startswith("SELECT payload") + assert "parent_execution_accounts" in queries[0] + assert len(first_page) == min(page_size, 5 if open_only else 10) + print(f"{config['backend']} venue={venue} open_only={open_only} limit={page_size}: " + f"{len(queries)} select, {len(first_page)} rows, no per-row scope queries") + rows, cursor = [], None + while True: + page = await store.orders_page(venue=venue, open_only=open_only, before=cursor, limit=3) + if not page: + break + rows.extend(page) + cursor = page[-1][1] + expected = [order for order in parents + concrete if (order.execution_accounts or order.venue == venue) + and (not open_only or not order.is_terminal)] + assert {(order.venue, order.id) for order, _ in rows} == {(order.venue, order.id) for order in expected} + assert len(rows) == len(expected) + # Venue/id ordering follows the database collation, which may + # differ from Python codepoint ordering (for example en_US). + # Page boundaries must reproduce that backend's complete order. + unpaged = await store.orders_page(venue=venue, open_only=open_only, limit=None) + assert [key for _, key in rows] == [key for _, key in unpaged] + assert sum(bool(order.execution_accounts) for order, _ in rows) == (4 if open_only else 8) + account = ACCOUNT_K if venue == "kalshi" else ACCOUNT_P + opened = await store.open_orders(account_key=account.key) + assert {(order.venue, order.id) for order in opened} == { + (order.venue, order.id) for order in parents + concrete + if not order.is_terminal and (order.execution_accounts or order.account == account)} + assert await store.open_orders(account_key="kalshi:unrelated") == [] + assert await store.orders_page(venue="unrelated", limit=3) == [] + monkeypatch.setattr(store._db, "execute", execute) + # Ask each backend to explain the actual production membership queries. + # Tiny PostgreSQL fixtures prefer sequential scans; disabling that option + # verifies index eligibility rather than claiming a production cost estimate. + if config["backend"] == "postgresql": + await store._db.execute("SET enable_seqscan=off") + for name, (query, params) in {"venue-page": next(item for item in captured if "created_ts" in item[0]), + "account-open": next(item for item in captured if "scope.account_key" in item[0])}.items(): + prefix = "EXPLAIN QUERY PLAN " if config["backend"] == "sqlite" else "EXPLAIN " + async with store._db.execute(prefix + query, params) as cursor: + plan = "\n".join(str(dict(row)) for row in await cursor.fetchall()) + assert "parent_execution" in plan + assert "INDEX" in plan.upper(), plan + assert "orders" in plan + assert any("parent_execution" in line and "INDEX" in line.upper() for line in plan.splitlines()), plan + print(f"{config['backend']} {name}: {plan}") + finally: + await engine.stop() + + +async def test_http_membership_pagination_rejects_old_cursor(tmp_path): + engine, _, _, _, bucket = await build(tmp_path) + store = await ControlStore(str(tmp_path / "cursor-control.db")).open() + try: + first = await engine.submit(request(bucket, client_order_id="page-first")) + await engine.cancel(first.id) + second = await engine.submit(request(bucket, client_order_id="page-second")) + _, key = await store.bootstrap("owner") + auth = {"Authorization": f"Bearer {key.secret}"} + with TestClient(create_trading_app(engine, store)) as client: + for venue in ("kalshi", "polymarket"): + seen, cursor = [], None + while True: + params = {"venue": venue, "scope": "all", "limit": 1} + if cursor: + params["cursor"] = cursor + response = client.get("/orders", params=params, headers=auth) + assert response.status_code == 200 + page = response.json() + seen.extend(order["id"] for order in page["data"]) + cursor = page["next_cursor"] + if not cursor: + break + assert set(seen) == {first.id, second.id} and len(seen) == 2 + old = base64.urlsafe_b64encode(json.dumps([1000, "kalshi", first.id]).encode()).decode().rstrip("=") + assert client.get("/orders", params={"scope": "all", "cursor": old}, headers=auth).status_code == 400 + finally: + await store.close() + await engine.stop() diff --git a/tests/test_routed_execution_scope.py b/tests/test_routed_execution_scope.py new file mode 100644 index 0000000..1aaf068 --- /dev/null +++ b/tests/test_routed_execution_scope.py @@ -0,0 +1,353 @@ +"""Cross-venue identity through real engine, HTTP, restart and both stores. + +All adapters are PaperVenue; these tests never use exchange credentials. +""" +from __future__ import annotations + +import json +from decimal import Decimal as D + +import pytest +from fastapi.testclient import TestClient + +from synpath.bucket import BucketMember +from synpath.engine import RiskConfig +from synpath.engine.storage import create_journal +from synpath.engine.storage_types import IntentState +from synpath.errors import BadRequest +from synpath.server.order_access import may_view_event +from synpath.server.store import ControlStore +from synpath.server.trading import create_trading_app +from synpath.trading.errors import RiskRejected, SubmissionPending +from synpath.trading.types import Account, Order, OrderRequest, OrderType, Side + +from test_routed_limit import build, book, request, K, P, ACCOUNT_K, ACCOUNT_P +from test_routed_recovery import resting_legs, new_engine +from test_owner_migrations import configuration, config # noqa: F401 +from test_postgres_storage import database # noqa: F401 + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +async def scope_engine(tmp_path): + engine, kalshi, poly, clock, bucket = await build(tmp_path) + book(kalshi, engine, K, asks=[(D("0.44"), D("100"))]) + book(poly, engine, P, asks=[(D("0.40"), D("100"))]) + yield engine, kalshi, poly, clock, bucket + await engine.stop() + + +async def test_reordering_members_preserves_owner_scope_and_nonfirst_allocation(scope_engine): + engine, kalshi, poly, _, bucket = scope_engine + first = await engine.submit(request(bucket, quantity=D("10"), client_order_id="original")) + reverse = bucket.model_copy(update={"members": list(reversed(bucket.members))}) + await engine.save_bucket(reverse) + second = await engine.submit(request(reverse, quantity=D("10"), client_order_id="reversed")) + assert first.venue is second.venue is None and first.account is second.account is None + assert first.execution_accounts == second.execution_accounts == {"kalshi": ACCOUNT_K, "polymarket": ACCOUNT_P} + assert first.user_id == second.user_id == engine.config.user_id + assert not kalshi.orders and [o.quantity for o in poly.orders.values()] == [D("10"), D("10")] + assert all(o.venue == "polymarket" and o.account == ACCOUNT_P for o in poly.orders.values()) + for venue in ("kalshi", "polymarket"): + rows = await engine.journal.orders_page(venue=venue) + assert sum(o.id == first.id for o, _ in rows) == 1 + assert (await engine.journal.order(None, first.id)).execution_accounts == first.execution_accounts + + +async def test_flipped_child_uses_member_account_and_native_side(tmp_path): + engine, kalshi, poly, _, bucket = await build(tmp_path, flip_p=True) + try: + book(poly, engine, P, bids=[(D("0.70"), D("100"))]) + book(kalshi, engine, K, asks=[(D("0.40"), D("100"))]) + parent = await engine.submit(request(bucket, quantity=D("10"), outcome="yes", action="buy")) + child = next(iter(poly.orders.values())) + assert parent.venue is None and not kalshi.orders + assert (child.account, child.side, child.price, child.outcome) == (ACCOUNT_P, Side.SELL, D("0.70"), "no") + finally: + await engine.stop() + + +@pytest.mark.parametrize("rule", ["capacity", "halt", "restricted"]) +async def test_nonfirst_destination_risk_refuses_before_dispatch(scope_engine, rule): + engine, kalshi, poly, _, bucket = scope_engine + if rule == "capacity": + # A 50/50 split fits but the agreed full-capacity policy refuses 100. + engine.order_risk_checks.configure(RiskConfig(max_position_contracts=D("60"), price_collar=None)) + elif rule == "halt": + engine.order_risk_checks.kill.engage(reason="test", scope="polymarket", policy="hold") + else: + engine.order_risk_checks.configure(RiskConfig(restricted=[P], price_collar=None)) + with pytest.raises(RiskRejected): + await engine.submit(request(bucket, quantity=D("100"))) + assert not kalshi.orders and not poly.orders + + +async def test_capture_is_used_once_across_authorization_and_concurrent_bucket_edit(scope_engine, monkeypatch): + engine, kalshi, poly, _, bucket = scope_engine + original_read = engine.journal.bucket + reads = [] + + async def read_then_edit(bucket_id): + row = await original_read(bucket_id) + reads.append(bucket_id) + await engine.save_bucket(bucket.model_copy(update={"members": [ + BucketMember(market_id=K), BucketMember(market_id="polymarket_us:new")]})) + return row + + monkeypatch.setattr(engine.journal, "bucket", read_then_edit) + authorized = [] + parent = await engine.submit(request(bucket), authorize_execution_accounts=lambda accounts: authorized.append(set(accounts))) + assert reads == [bucket.id] and authorized == [{"kalshi", "polymarket"}] + assert set(parent.execution_accounts) == {"kalshi", "polymarket"} + assert poly.orders and not kalshi.orders + + +async def test_same_client_id_retry_keeps_original_snapshot_after_bucket_edit(scope_engine): + engine, _, poly, _, bucket = scope_engine + instruction = request(bucket, client_order_id="retry") + first = await engine.submit(instruction) + await engine.save_bucket(bucket.model_copy(update={"members": [ + BucketMember(market_id=K), BucketMember(market_id=P, enabled=False)]})) + again = await engine.submit(instruction) + assert first.id == again.id and first.execution_accounts == again.execution_accounts + assert len(poly.orders) == 1 + with pytest.raises(BadRequest, match="different instruction"): + await engine.submit(instruction.model_copy(update={"quantity": D("1")})) + + +async def test_singular_parent_account_and_nullable_venue_order_are_rejected(scope_engine): + engine, _, _, _, bucket = scope_engine + with pytest.raises(BadRequest, match="singular account"): + await engine.submit(request(bucket, account=ACCOUNT_K)) + with pytest.raises(ValueError, match="captured execution accounts"): + Order(id="bad", venue=None, market_id=K, side="buy", type="limit", time_in_force="gtc", status="open", quantity="1") + + +async def test_legacy_client_id_without_verifiable_reservation_cannot_create_another_parent(scope_engine): + engine, _, poly, _, bucket = scope_engine + instruction = request(bucket, client_order_id="legacy-client") + parent = await engine.submit(instruction) + await engine.journal._db.execute("DELETE FROM submissions WHERE client_order_id=?", (instruction.client_order_id,)) + with pytest.raises(BadRequest, match="older bucket order"): + await engine.submit(instruction) + assert len(poly.orders) == 1 and (await engine._known(parent.id, None)).id == parent.id + + +@pytest.mark.parametrize("supplied_scope", [{"kalshi": ACCOUNT_K.model_dump(mode="json")}, {"kalshi": "malformed"}]) +async def test_caller_scope_params_never_authorize_the_full_reserved_request(scope_engine, tmp_path, supplied_scope): + engine, _, _, _, bucket = scope_engine + parent = await engine.submit(request(bucket, params={"execution_accounts": supplied_scope})) + store = await ControlStore(str(tmp_path / "audit-control.db")).open() + try: + owner, _ = await store.bootstrap("owner") + viewer = await store.create_user("partial") + await store.acting_as(f"user:{owner.id}", "test") + await store.grant(viewer.id, ACCOUNT_K.key, "view") + key = await store.issue_key(viewer.id) + principal = await store.principal(key.secret) + events = [event async for event in engine.journal.replay(0)] + reserved = next(event for event in events if event.kind == "submission.reserved") + assert set(reserved.payload["effective_request"]["params"]["execution_accounts"]) == {"kalshi", "polymarket"} + assert not await may_view_event(principal, engine, reserved) + with TestClient(create_trading_app(engine, store)) as client: + response = client.get("/journal", headers={"Authorization": f"Bearer {key.secret}"}) + assert response.status_code == 200 and parent.id not in response.text + assert "effective_request" not in response.text + finally: + await store.close() + + +async def test_changed_adapter_identity_blocks_bulk_reads_and_halt(scope_engine, monkeypatch): + engine, _, poly, _, bucket = scope_engine + engine.books.clear() + await engine.submit(request(bucket)) + # Keep the parent active but change only the actual adapter's account. + poly.account = Account(venue="polymarket", name="replacement") + calls = [] + + async def wrong_account(*args, **kwargs): + calls.append("wrong-account I/O") + raise AssertionError("must not contact replacement account") + + for name in ("fetch_open_orders", "fetch_my_trades", "fetch_positions", "fetch_balance", "cancel_all_orders"): + monkeypatch.setattr(poly, name, wrong_account) + with pytest.raises(SubmissionPending): + await engine.poll("polymarket") + from synpath.engine.reconcile import Reconciler + with pytest.raises(SubmissionPending): + await Reconciler(engine).run("polymarket") + result = await engine.halt("test", scope="polymarket") + assert "failed" in result["canceled"]["polymarket"] and calls == [] + + +async def test_parent_detail_reports_logs_and_legacy_events_use_saved_scope(scope_engine, tmp_path): + engine, _, _, _, bucket = scope_engine + parent = await engine.submit(request(bucket)) + store = await ControlStore(str(tmp_path / "control.db")).open() + try: + owner, owner_key = await store.bootstrap("owner") + partial = await store.create_user("partial") + await store.acting_as(f"user:{owner.id}", "test grants") + await store.grant(partial.id, ACCOUNT_K.key, "view") + await store.grant(partial.id, ACCOUNT_K.key, "trade") + key = await store.issue_key(partial.id, label="one venue") + partial_principal = await store.principal(key.secret) + auth = {"Authorization": f"Bearer {key.secret}"} + full = {"Authorization": f"Bearer {owner_key.secret}"} + with TestClient(create_trading_app(engine, store)) as client: + assert client.get(f"/orders/{parent.id}", headers=auth).status_code == 403 + assert client.delete(f"/orders/{parent.id}", headers=auth).status_code == 403 + assert client.patch(f"/orders/{parent.id}", headers=auth, json={"price": "0.43"}).status_code == 403 + assert client.get(f"/order-log?order_id={parent.id}", headers=auth).status_code == 403 + for path in ("/journal", "/order-log", "/orders?scope=all"): + assert parent.id not in json.dumps(client.get(path, headers=auth).json()) + assert client.get(f"/orders/{parent.id}", headers=full).json()["execution_accounts"]["polymarket"] + # The saved scope still governs history after editing the current definition. + await engine.save_bucket(bucket.model_copy(update={"members": [ + BucketMember(market_id=K), BucketMember(market_id=P, enabled=False)]})) + reports = client.get(f"/buckets/{bucket.id}/orders", headers=auth) + assert parent.id not in reports.text + assert client.get(f"/buckets/{bucket.id}/orders/{parent.id}", headers=auth).status_code == 403 + await store.grant(partial.id, ACCOUNT_P.key, "view") + await engine.save_bucket(bucket.model_copy(update={"members": [ + BucketMember(market_id=K), BucketMember(market_id="polymarket_us:later")]})) + # Access to the original history cannot depend on newly added accounts. + assert client.get(f"/buckets/{bucket.id}/orders/{parent.id}", headers=auth).status_code == 200 + assert parent.id in client.get(f"/buckets/{bucket.id}/orders", headers=auth).text + from synpath.engine.storage_types import JournalEvent + principal = partial_principal + legacy = JournalEvent(1, 1, "managed.working", parent.id, {"id": parent.id, "venue": "kalshi", "account": ACCOUNT_K.model_dump()}) + assert not await may_view_event(principal, engine, legacy) + finally: + await store.close() + + +async def test_changed_account_blocks_recovery_reads_cancels_and_false_lost(tmp_path, monkeypatch): + engine, kalshi, poly, clock, path, parent = await resting_legs(tmp_path) + managed = engine.managed_order_controller.get(parent.id) + child = managed.children[1] + child_order = await engine.journal.order(child.venue, child.order_id) + pending = OrderRequest(market_id=P, side=Side.BUY, quantity=D("1"), price=D("0.42"), + account=ACCOUNT_P, tags={"parent": parent.id}, client_order_id="uncertain") + await engine.journal.record_intent(pending, client_order_id="uncertain", venue="polymarket", account=ACCOUNT_P) + await engine.journal.mark_intent("uncertain", IntentState.SENDING) + await engine.journal.close() + calls = [] + + async def wrong_account(*args, **kwargs): + calls.append((args, kwargs)) + raise AssertionError("must not contact the replacement account") + + for name in ("fetch_order", "fetch_open_orders", "fetch_orders", "cancel_order"): + monkeypatch.setattr(poly, name, wrong_account) + restarted = new_engine(path, kalshi, poly, clock) + restarted.accounts["polymarket"] = Account(venue="polymarket", name="replacement") + await restarted.start() + try: + await restarted.managed_order_controller.on_timer() + with pytest.raises(SubmissionPending, match="differs"): + await restarted.cancel(child.order_id, venue=child.venue) + assert calls == [] + assert (await restarted.journal.intent("uncertain")).state == IntentState.SENDING + assert (await restarted.journal.order(child.venue, child.order_id)).status == child_order.status + assert restarted.managed_order_controller.get(parent.id).live + finally: + await restarted.stop() + + +@pytest.mark.parametrize("failure_boundary", ["membership", "projection", "event"]) +async def test_atomic_parent_snapshot_scope_projection_rollback_on_both_stores(config, tmp_path, monkeypatch, failure_boundary): + engine, _, _, _, bucket = await build(tmp_path) + try: + parent_order = await engine.submit(request(bucket, client_order_id="atomic")) + snapshot = engine.managed_order_controller.get(parent_order.id).snapshot() + async with create_journal(**config, user_id=engine.config.user_id) as store: + execute = store._db.execute + + def fail(query, params=()): + match = {"membership": "INSERT INTO parent_execution_accounts", "projection": "INSERT INTO orders(", + "event": "INSERT INTO events("}[failure_boundary] + if query.startswith(match): + raise RuntimeError("injected write failure") + return execute(query, params) + + monkeypatch.setattr(store._db, "execute", fail) + with pytest.raises(RuntimeError, match="injected"): + await store.save_managed(snapshot, order=parent_order) + monkeypatch.setattr(store._db, "execute", execute) + assert await store.managed_one(parent_order.id) is None + assert await store.order(None, parent_order.id) is None + assert await store.routed_account_keys("kalshi") == [] + assert await store.last_seq() == 0 + await store.save_managed(snapshot, order=parent_order) + assert (await store.order(None, parent_order.id)).execution_accounts == parent_order.execution_accounts + assert (await store.managed_one(parent_order.id))[1]["execution_accounts"] + finally: + await engine.stop() + + +@pytest.mark.parametrize("historical_evidence", ["complete", "missing", "explicit", "conflict"]) +async def test_legacy_nominal_parent_migration_preserves_or_refuses_accounts(config, tmp_path, historical_evidence): + engine, _, _, _, bucket = await build(tmp_path) + try: + parent_order = await engine.submit(request(bucket)) + snapshot = engine.managed_order_controller.get(parent_order.id).snapshot() + # Make an actual pre-PRE-6 snapshot and projection, keeping its original IDs. + snapshot.update(venue="kalshi", account=ACCOUNT_K.model_dump(mode="json")) + snapshot.pop("execution_accounts") + snapshot["request"]["account"] = ACCOUNT_K.model_dump(mode="json") + snapshot["request"]["params"].pop("execution_accounts") + legacy_projection = parent_order.model_dump(mode="json") + legacy_projection.update(venue="kalshi", account=ACCOUNT_K.model_dump(mode="json")) + legacy_projection.pop("execution_accounts") + async with create_journal(**config, user_id=engine.config.user_id) as store: + await store.save_managed(snapshot) + await store.upsert_order(Order.model_validate(legacy_projection)) + if historical_evidence in ("complete", "conflict"): + child = Order(id="historical-child", venue="polymarket", account=ACCOUNT_P, market_id=P, + side="buy", type="limit", time_in_force="gtc", status="open", quantity="1", + tags={"parent": parent_order.id}, user_id=engine.config.user_id) + await store.upsert_order(child) + if historical_evidence in ("explicit", "conflict"): + account = ACCOUNT_P if historical_evidence == "explicit" else Account(venue="polymarket", name="conflict") + await store._db.execute("INSERT INTO meta(key,value) VALUES('legacy_execution_accounts',?)", + (json.dumps({parent_order.id: {"polymarket": account.model_dump(mode="json")}}),)) + await store._db.execute("DROP TABLE parent_execution_accounts") + await store._db.execute("UPDATE alembic_version SET version_num='0005_drop_fair_values'") + await store._db.execute("UPDATE meta SET value='10' WHERE key='schema_version'") + reopened = create_journal(**config, user_id=engine.config.user_id) + if historical_evidence in ("missing", "conflict"): + with pytest.raises(ValueError, match="historical execution accounts|conflicting historical"): + await reopened.open() + assert not reopened.is_open + # Retry using a reviewed explicit mapping for the missing venue. + if historical_evidence == "missing": + from synpath.engine.database import connect_sqlite, connect_postgres + connection = await (connect_sqlite(config["path"]) if config["backend"] == "sqlite" + else connect_postgres(config["path"])) + if config["backend"] == "postgresql": + await connection.execute(f'SET search_path TO "{config["schema"]}", pg_catalog') + async with connection.execute("SELECT value FROM meta WHERE key='schema_version'") as cursor: + assert (await cursor.fetchone())["value"] == "10" + await connection.execute("INSERT INTO meta(key,value) VALUES('legacy_execution_accounts',?)", + (json.dumps({parent_order.id: {"polymarket": ACCOUNT_P.model_dump(mode="json")}}),)) + await connection.close() + else: + return + async with reopened: + migrated = await reopened.order(None, parent_order.id) + assert migrated.id == parent_order.id and migrated.venue is None and migrated.account is None + assert migrated.execution_accounts == {"kalshi": ACCOUNT_K, "polymarket": ACCOUNT_P} + assert (await reopened.managed_one(parent_order.id))[1]["legacy_execution_identity"]["venue"] == "kalshi" + assert await reopened.order("kalshi", parent_order.id) is None + assert sum(order.id == parent_order.id for order, _ in await reopened.orders_page()) == 1 + async with reopened: + assert (await reopened.order(None, parent_order.id)).execution_accounts == migrated.execution_accounts + finally: + await engine.stop() diff --git a/tests/test_routed_limit.py b/tests/test_routed_limit.py index 6f3fb03..5d8b47e 100644 --- a/tests/test_routed_limit.py +++ b/tests/test_routed_limit.py @@ -95,7 +95,7 @@ async def test_legs_land_on_both_venues_and_the_parent_reports_in_bucket_terms(s assert [(o.side, o.price, o.quantity) for o in poly.orders.values()] == [(Side.BUY, D("0.42"), D("20"))] await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "done" and managed.filled == D("50") assert managed.as_order().average_price == (D("0.41") * 30 + D("0.42") * 20) / 50 report = managed.report() @@ -126,11 +126,23 @@ async def test_a_flipped_member_is_sold_at_one_minus(self, tmp_path): assert [(o.side, o.price, o.quantity) for o in poly.orders.values()] == [(Side.SELL, D("0.70"), D("40"))] assert kalshi.orders == {} await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "done" and managed.as_order().average_price == D("0.30") finally: await engine.stop() + async def test_a_member_switched_off_gets_no_leg(self, setup): + engine, kalshi, poly, clock, bucket = setup + off = await engine.save_bucket(bucket.model_copy(update={"members": [ + BucketMember(market_id=K), BucketMember(market_id=P, enabled=False)]})) + book(kalshi, engine, K, asks=[(D("0.44"), D("30"))]) + book(poly, engine, P, asks=[(D("0.40"), D("100"))]) + parent = await engine.submit(request(off)) + assert [(o.price, o.quantity) for o in kalshi.orders.values()] == [(D("0.44"), D("30"))] + assert poly.orders == {}, "the cheaper member is off, so nothing goes there" + managed = engine.managed_order_controller.get(parent.id) + assert managed.markets() == [K] and managed.venues() == {"kalshi"} + class TestWorking: async def test_a_partial_fill_leaves_the_remainder_resting_and_the_rest_completes_it(self, setup): @@ -140,7 +152,7 @@ async def test_a_partial_fill_leaves_the_remainder_resting_and_the_rest_complete kalshi.set_book(K, asks=[(D("0.41"), D("10"))]) # someone else took 20 before our leg arrived parent = await engine.submit(request(bucket)) await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.state == "working" and managed.filled == D("30") resting = open_orders(kalshi) assert [(o.price, o.quantity, o.filled) for o in resting] == [(D("0.41"), D("30"), D("10"))] @@ -162,7 +174,7 @@ async def test_only_the_leg_whose_price_changed_is_replaced(self, setup): poly.set_book(P, asks=[(D("0.50"), D("100"))]) parent = await engine.submit(request(bucket)) await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) first_k, first_p = list(kalshi.orders)[0], list(poly.orders)[0] engine.set_book(K, BookState(asks=[(D("0.40"), D("30"))])) @@ -199,7 +211,7 @@ async def test_nothing_inside_the_limit_stops_it_and_says_so(self, setup): book(kalshi, engine, K, asks=[(D("0.50"), D("100"))]) book(poly, engine, P, asks=[(D("0.52"), D("100"))]) parent = await engine.submit(request(bucket)) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert parent.status == OrderStatus.CANCELED assert managed.stop_reason == "worst_price" and managed.report()["unfilled"] == "50" assert kalshi.orders == {} and poly.orders == {} @@ -213,7 +225,7 @@ async def test_max_rounds_stops_it_with_a_partial(self, setup): # A fill re-plans but changes no leg, so it is not a round; the book move is round 1. parent = await engine.submit(request(bucket, params={"max_rounds": 1})) await deliver(kalshi, poly) - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.filled == D("10") and managed.state == "working" and managed.rounds == 0 engine.set_book(K, BookState(asks=[(D("0.40"), D("30"))])) await engine.on_book({"market_id": K}) @@ -229,9 +241,9 @@ async def test_cancelling_the_parent_pulls_every_leg_on_every_venue(self, setup) poly.set_book(P, asks=[(D("0.50"), D("100"))]) parent = await engine.submit(request(bucket)) assert open_orders(kalshi) and open_orders(poly) - await engine.orders.cancel(parent.id) + await engine.managed_order_controller.cancel(parent.id) assert open_orders(kalshi) == [] and open_orders(poly) == [] - assert engine.orders.get(parent.id).state == "canceled" + assert engine.managed_order_controller.get(parent.id).state == "canceled" async def test_a_halt_on_one_member_venue_reaches_it(self, setup): engine, kalshi, poly, clock, bucket = setup @@ -240,10 +252,10 @@ async def test_a_halt_on_one_member_venue_reaches_it(self, setup): kalshi.set_book(K, asks=[(D("0.50"), D("100"))]) poly.set_book(P, asks=[(D("0.50"), D("100"))]) parent = await engine.submit(request(bucket)) - halted = await engine.orders.on_halt("test", scope="polymarket") + halted = await engine.managed_order_controller.on_halt("test", scope="polymarket") assert halted == 1 assert open_orders(kalshi) == [] and open_orders(poly) == [] - assert engine.orders.get(parent.id).state == "canceled" + assert engine.managed_order_controller.get(parent.id).state == "canceled" class TestPosition: @@ -318,7 +330,7 @@ async def test_the_curve_is_read_under_the_floor_and_a_small_leg_goes_elsewhere( book(poly, engine, P, asks=[(D("0.400"), D("10"))]) parent = await engine.submit(request(bucket, quantity=D("40"))) assert asked[:2] == [D("100"), D("10000")] - managed = engine.orders.get(parent.id) + managed = engine.managed_order_controller.get(parent.id) assert managed.fee_cache[f"{P}@0.400"] == D("0.0001") and managed.fee_floors[P] == D("0.25") # Polymarket's 10 are 0.2c cheaper but pay 0.25 in fees: Kalshi takes all 40. assert poly.orders == {} and [o.quantity for o in kalshi.orders.values()] == [D("40")] @@ -330,3 +342,169 @@ async def test_a_floor_worth_paying_is_paid(self, setup): book(poly, engine, P, asks=[(D("0.40"), D("1000"))]) await engine.submit(request(bucket, quantity=D("50"), price=D("0.46"))) assert kalshi.orders == {} and [o.quantity for o in poly.orders.values()] == [D("50")] + + +class TestPendingCancellation: + """A cancellation response may precede the final child status.""" + + async def resting_parent(self, setup, **request_fields): + engine, kalshi, poly, clock, bucket = setup + book(kalshi, engine, K, asks=[(D("0.41"), D("30"))]) + book(poly, engine, P, asks=[(D("0.42"), D("100"))]) + # The engine saw liquidity, but it moved before the children reached the venues. + kalshi.set_book(K, asks=[(D("0.50"), D("100"))]) + poly.set_book(P, asks=[(D("0.50"), D("100"))]) + order = await engine.submit(request(bucket, **request_fields)) + return engine.managed_order_controller.get(order.id) + + def acknowledge_without_canceling(self, monkeypatch, *venues): + original_cancellations = {} + for venue in venues: + original_cancellations[venue.venue] = venue.cancel_order + + async def acknowledge(order_id, *, market_id=None, current=None, venue=venue): + return await venue.fetch_order(order_id) + + monkeypatch.setattr(venue, "cancel_order", acknowledge) + return original_cancellations + + async def confirm_cancellations(self, engine, venues, original_cancellations): + for venue in venues: + for child_order in list(open_orders(venue)): + result = await original_cancellations[venue.venue](child_order.id) + await engine.on_order(result) + + async def test_delayed_child_confirmation_finishes_a_cancelling_parent(self, setup, monkeypatch): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + original_cancellations = self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + original_child_ids = {(child.venue, child.order_id) for child in parent.children} + + await engine.managed_order_controller.cancel(parent.id) + assert parent.state == "cancelling" and len(parent.live_children) == 2 + await engine.on_book({"market_id": K}) + assert {(child.venue, child.order_id) for child in parent.children} == original_child_ids + + await self.confirm_cancellations(engine, (kalshi, poly), original_cancellations) + assert parent.state == "canceled" + assert parent.filled == 0 and parent.remaining == 50 + assert not parent.live_children and not parent.legs + assert len(kalshi.orders) + len(poly.orders) == 2 + + @pytest.mark.parametrize("fill_all", [False, True]) + async def test_fills_during_cancellation_are_retained_and_full_fill_wins(self, setup, monkeypatch, fill_all): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + original_cancellations = self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + await engine.managed_order_controller.cancel(parent.id) + + kalshi.on_trade(K, price=D("0.41"), quantity=D("10"), taker_side=Side.SELL) + await deliver(kalshi, poly) + assert parent.filled == 10 and parent.state == "cancelling" + if fill_all: + kalshi.on_trade(K, price=D("0.41"), quantity=D("20"), taker_side=Side.SELL) + poly.on_trade(P, price=D("0.42"), quantity=D("20"), taker_side=Side.SELL) + await deliver(kalshi, poly) + else: + await self.confirm_cancellations(engine, (kalshi, poly), original_cancellations) + + assert parent.state == ("done" if fill_all else "canceled") + assert parent.filled == (50 if fill_all else 10) + assert not parent.live_children + assert len(kalshi.orders) + len(poly.orders) == 2 + + @pytest.mark.parametrize("first_attempt_fails", [False, True]) + async def test_timer_retries_pending_cancellations_without_new_allocations( + self, setup, monkeypatch, first_attempt_fails, + ): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + attempts = {venue.venue: 0 for venue in (kalshi, poly)} + for venue in (kalshi, poly): + original_cancel = venue.cancel_order + + async def cancel_after_retry( + order_id, *, market_id=None, current=None, venue=venue, original_cancel=original_cancel, + ): + attempts[venue.venue] += 1 + if attempts[venue.venue] == 1: + if first_attempt_fails: + raise TimeoutError("cancellation response was lost") + return await venue.fetch_order(order_id) + return await original_cancel(order_id, market_id=market_id) + + monkeypatch.setattr(venue, "cancel_order", cancel_after_retry) + + await engine.managed_order_controller.cancel(parent.id) + assert parent.state == "cancelling" + await engine.managed_order_controller.on_timer() + assert parent.state == "canceled" and not parent.live_children + assert attempts == {"kalshi": 2, "polymarket": 2} + assert len(kalshi.orders) + len(poly.orders) == 2 + + async def test_routing_stop_enters_cancelling_before_venue_callbacks(self, setup, monkeypatch): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + states_at_cancel = [] + replan_calls = [] + + async def record_replan(ctx, **kwargs): + replan_calls.append(parent.state) + + monkeypatch.setattr(parent, "replan", record_replan) + for venue in (kalshi, poly): + original_cancel = venue.cancel_order + + async def observe_cancel(order_id, *, market_id=None, current=None, original_cancel=original_cancel): + states_at_cancel.append(parent.state) + # Simulate a book update arriving inside a cancellation call. + await engine.on_book({"market_id": K}) + return await original_cancel(order_id, market_id=market_id) + + monkeypatch.setattr(venue, "cancel_order", observe_cancel) + + await engine.managed_order_controller.run(parent, lambda ctx: parent.stop(ctx, "max_rounds")) + assert states_at_cancel == ["cancelling", "cancelling"] + assert not replan_calls, "neither book nor cancellation callbacks may resume routing" + assert parent.state == "canceled" and parent.stop_reason == "max_rounds" + assert len(kalshi.orders) + len(poly.orders) == 2 + + async def test_delayed_expiry_keeps_its_intended_final_state(self, setup, monkeypatch): + engine, kalshi, poly, clock, bucket = setup + expiry = int((clock.t + 10) * 1000) + parent = await self.resting_parent(setup, time_in_force="gtd", expires_at=expiry) + original_cancellations = self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + clock.tick(11) + await engine.managed_order_controller.on_timer() + assert parent.state == "cancelling" + + await self.confirm_cancellations(engine, (kalshi, poly), original_cancellations) + assert parent.state == "expired" and not parent.live_children + assert parent.filled == 0 and parent.remaining == 50 + + + @pytest.mark.parametrize("end", ["canceled", "expired"]) + async def test_pending_final_state_survives_snapshot_restore(self, setup, monkeypatch, end): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + await engine.managed_order_controller.run( + parent, lambda ctx: parent.cancel(ctx, "test pending ending", end=end), + ) + snapshot = parent.snapshot() + restored = type(parent).from_snapshot(snapshot) + assert restored.state == "cancelling" + assert restored.pending_final_state == end + assert not restored._resynced, "the restored child records still need a venue refresh attempt" + + async def test_legacy_expiry_snapshot_preserves_expired_ending(self, setup, monkeypatch): + engine, kalshi, poly, clock, bucket = setup + parent = await self.resting_parent(setup) + self.acknowledge_without_canceling(monkeypatch, kalshi, poly) + await engine.managed_order_controller.run( + parent, lambda ctx: parent.cancel(ctx, "expired", end="expired"), + ) + snapshot = parent.snapshot() + snapshot["extra"].pop("pending_final_state") + restored = type(parent).from_snapshot(snapshot) + assert restored.state == "cancelling" and restored.pending_final_state == "expired" diff --git a/tests/test_routed_recovery.py b/tests/test_routed_recovery.py index 6c78515..556dac2 100644 --- a/tests/test_routed_recovery.py +++ b/tests/test_routed_recovery.py @@ -87,10 +87,10 @@ async def test_a_fill_while_down_is_counted_once_and_nothing_is_placed_twice(sel recovery = await restarted.start() try: assert recovery.managed == 1 - revived = restarted.orders.get(parent.id) + revived = restarted.managed_order_controller.get(parent.id) assert revived.state == "working" and revived.known_filled == 0, "nothing is trusted before the venues are asked" - await restarted.orders.on_timer() # first event after the restart: resync + await restarted.managed_order_controller.on_timer() # first event after the restart: resync assert revived.known_filled == D("30") assert revived.as_order().filled == D("30") and revived.report()["per_venue"] assert len(kalshi.orders) == 1 and len(poly.orders) == 1, "the fill was learned, not bought again" @@ -115,13 +115,13 @@ async def test_a_leg_cancelled_while_down_is_re_placed_within_the_remainder(self restarted = new_engine(path, kalshi, poly, clock) await restarted.start() try: - revived = restarted.orders.get(parent.id) + revived = restarted.managed_order_controller.get(parent.id) restarted.set_book(K, kalshi.books[K]) restarted.set_book(P, poly.books[P]) kalshi.set_book(K, asks=[(D("0.41"), D("100"))]) restarted.set_book(K, kalshi.books[K]) kalshi.set_book(K, asks=[(D("0.50"), D("100"))]) - await restarted.orders.on_timer() + await restarted.managed_order_controller.on_timer() live_k = open_orders(kalshi) assert len(live_k) == 1 and live_k[0].price == D("0.41") resting = sum(o.quantity - o.filled for o in open_orders(kalshi) + open_orders(poly)) @@ -145,12 +145,12 @@ async def test_a_child_the_snapshot_missed_is_adopted_from_the_journal(self, tmp restarted = new_engine(path, kalshi, poly, clock) await restarted.start() try: - revived = restarted.orders.get(parent.id) + revived = restarted.managed_order_controller.get(parent.id) assert len(revived.children) == 2 - await restarted.orders.on_timer() + await restarted.managed_order_controller.on_timer() assert len(revived.children) == 3 and revived.child_of(stray.id, "kalshi") is not None assert sum(c.quantity for c in revived.live_children) <= D("50") - assert restarted.orders.parent_of(stray.id, "kalshi") is revived + assert restarted.managed_order_controller.parent_of(stray.id, "kalshi") is revived finally: await restarted.stop() @@ -164,7 +164,7 @@ async def test_a_finished_bucket_order_is_still_reported_after_a_restart(tmp_pat again = new_engine(path, kalshi, poly, clock) await again.start() try: - assert again.orders.get(parent.id) is None, "a finished parent is not reloaded as live" + assert again.managed_order_controller.get(parent.id) is None, "a finished parent is not reloaded as live" bucket_id = parent.market_id.split(":", 1)[1] found = await again.bucket_orders(bucket_id) assert [p.id for p in found] == [parent.id] @@ -174,3 +174,59 @@ async def test_a_finished_bucket_order_is_still_reported_after_a_restart(tmp_pat assert await again.bucket_order("nope") is None finally: await again.stop() + + +@pytest.mark.parametrize("ending", ["canceled", "expired"]) +async def test_pending_cancellation_resumes_after_sqlite_restart_without_new_orders(tmp_path, monkeypatch, ending): + engine, kalshi, poly, clock, path, order = await resting_legs(tmp_path) + parent = engine.managed_order_controller.get(order.id) + original_cancellations = {venue.venue: venue.cancel_order for venue in (kalshi, poly)} + for venue in (kalshi, poly): + async def acknowledge_only(order_id, *, market_id=None, current=None, venue=venue): + return await venue.fetch_order(order_id) + monkeypatch.setattr(venue, "cancel_order", acknowledge_only) + + await engine.managed_order_controller.run( + parent, lambda ctx: parent.cancel(ctx, "pending restart test", end=ending), + ) + assert parent.state == "cancelling" and len(parent.live_children) == 2 + await engine.journal.close() + + # The venue makes progress while this process cannot receive notifications. + # Restart must learn that progress before retrying the remaining cancellation. + kalshi.on_trade(K, price=D("0.41"), quantity=D("10"), taker_side=Side.SELL) + await original_cancellations["kalshi"](parent.children[0].order_id) + for venue in (kalshi, poly): + monkeypatch.setattr(venue, "cancel_order", original_cancellations[venue.venue]) + + refreshed = [] + for venue in (kalshi, poly): + original_fetch = venue.fetch_order + async def record_refresh(order_id, *, original_fetch=original_fetch, venue=venue): + refreshed.append((venue.venue, order_id)) + return await original_fetch(order_id) + monkeypatch.setattr(venue, "fetch_order", record_refresh) + + restarted = new_engine(path, kalshi, poly, clock) + await restarted.start() + try: + revived = restarted.managed_order_controller.get(order.id) + assert revived.state == "cancelling" and not revived._resynced + assert revived.pending_final_state == ending + # Supply attractive books: stopping parents must still refuse to allocate. + kalshi.set_book(K, asks=[(D("0.40"), D("100"))]) + poly.set_book(P, asks=[(D("0.40"), D("100"))]) + restarted.set_book(K, kalshi.books[K]) + restarted.set_book(P, poly.books[P]) + await restarted.managed_order_controller.on_timer() + + assert revived.state == ending and not revived.live_children + assert revived._resynced + assert set(refreshed) >= {(child.venue, child.order_id) for child in parent.children} + assert revived.filled == D("10") and revived.remaining == D("40") + assert len(kalshi.orders) == 1 and len(poly.orders) == 1 + assert open_orders(kalshi) == [] and open_orders(poly) == [] + stored = await restarted.journal.order(revived.venue, order.id) + assert stored.status == OrderStatus(ending) and stored.filled == D("10") + finally: + await restarted.stop() diff --git a/tests/test_routed_scope_transactions.py b/tests/test_routed_scope_transactions.py new file mode 100644 index 0000000..358eef0 --- /dev/null +++ b/tests/test_routed_scope_transactions.py @@ -0,0 +1,131 @@ +"""Post-write rollback and executable legacy routed-parent upgrade contracts.""" +import asyncio +import copy +from decimal import Decimal as D + +import pytest + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.engine.storage import create_journal +from synpath.trading.types import Order, OrderRequest, Side +from test_owner_migrations import configuration, config # noqa: F401 +from test_postgres_storage import database # noqa: F401 +from test_routed_limit import build, request, ACCOUNT_K, ACCOUNT_P, K +from test_routed_recovery import resting_legs + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.mark.parametrize("operation", ["save", "plan"]) +@pytest.mark.parametrize("interruption", ["failure", "cancellation"]) +async def test_parent_transaction_rolls_back_after_all_component_writes(config, tmp_path, monkeypatch, operation, interruption): + source, _, _, _, bucket = await build(tmp_path) + try: + original = request(bucket, client_order_id="post-write-atomic", user_id=source.config.user_id) + parent = await source.submit(original) + snapshot = source.managed_order_controller.get(parent.id).snapshot() + effective = OrderRequest.stored(snapshot["request"]) + child = OrderRequest(market_id=K, side="buy", quantity="1", price="0.40", account=ACCOUNT_K, + user_id=parent.user_id, client_order_id="planned-child", tags={"parent": parent.id}) + store = create_journal(**config, user_id=parent.user_id) + async with store: + await store.record_submission(original, effective, venue="__managed__", account=None) + baseline_seq = await store.last_seq() + entered = asyncio.Event() + helper_name = "_write_order" if operation == "save" else "_write_intent" + helper = getattr(store, helper_name) + + async def interrupt_after_write(*args, **kwargs): + await helper(*args, **kwargs) + entered.set() + if interruption == "failure": + raise RuntimeError("failure after all component writes") + await asyncio.Event().wait() + + monkeypatch.setattr(store, helper_name, interrupt_after_write) + action = (store.save_managed(snapshot, order=parent) if operation == "save" else + store.plan_managed_child(snapshot, child, venue="kalshi", account=ACCOUNT_K, parent_order=parent)) + task = asyncio.create_task(action) + await asyncio.wait_for(entered.wait(), 5) + if interruption == "cancellation": + task.cancel() + with pytest.raises(asyncio.CancelledError if interruption == "cancellation" else RuntimeError): + await task + monkeypatch.setattr(store, helper_name, helper) + assert await store.managed_one(parent.id) is None + assert await store.order(None, parent.id) is None + assert await store.routed_account_keys("kalshi") == [] + assert await store.intent("planned-child") is None + assert await store.last_seq() == baseline_seq + reservation = await store.submission(original.client_order_id) + assert reservation["result_id"] is None and reservation["state"] == "pending" + async with store: + assert await store.managed_one(parent.id) is None + assert await store.order(None, parent.id) is None + assert (await store.submission(original.client_order_id))["result_id"] is None + await store.save_managed(snapshot, order=parent) + assert (await store.submission(original.client_order_id))["result_id"] == parent.id + assert (await store.order(None, parent.id)).execution_accounts == parent.execution_accounts + finally: + await source.stop() + + +async def test_migrated_working_parent_restores_and_cancels_original_children(config, tmp_path): + source, kalshi, poly, clock, _, original = await resting_legs(tmp_path) + try: + kalshi.on_trade(K, price=D("0.41"), quantity=D("10"), taker_side=Side.SELL) + await kalshi.deliver() + managed = source.managed_order_controller.get(original.id) + assert managed.filled == D("10") and managed.state == "working" + snapshot = copy.deepcopy(managed.snapshot()) + projection = managed.as_order().model_dump(mode="json") + children = await source.journal.orders_for_parent(original.id) + snapshot.update(venue="kalshi", account=ACCOUNT_K.model_dump(mode="json")) + snapshot.pop("execution_accounts") + snapshot["request"]["account"] = ACCOUNT_K.model_dump(mode="json") + snapshot["request"]["params"].pop("execution_accounts") + projection.update(venue="kalshi", account=ACCOUNT_K.model_dump(mode="json")) + projection.pop("execution_accounts") + async with create_journal(**config, user_id=original.user_id) as store: + await store.save_managed(snapshot) + await store.upsert_order(Order.model_validate(projection)) + for child in children: + await store.upsert_order(child) + await store._db.execute("DROP TABLE parent_execution_accounts") + await store._db.execute("UPDATE alembic_version SET version_num='0005_drop_fair_values'") + await store._db.execute("UPDATE meta SET value='10' WHERE key='schema_version'") + finally: + await source.stop() + + journal = create_journal(**config, user_id=original.user_id) + restored = Engine({"kalshi": kalshi, "polymarket": poly}, + EngineConfig(user_id=original.user_id, require_lease=True), journal=journal, + accounts={"kalshi": ACCOUNT_K, "polymarket": ACCOUNT_P}, clock=clock, + risk=RiskConfig(price_collar=None, closing_soon_s=None)) + for paper in (kalshi, poly): + paper.listeners.clear() + paper.order_listeners.clear() + paper.subscribe(restored.on_fill) + paper.subscribe_orders(restored.on_order) + await restored.start() + try: + parent = restored.managed_order_controller.get(original.id) + assert parent is not None and parent.live + assert parent.execution_accounts == {"kalshi": ACCOUNT_K, "polymarket": ACCOUNT_P} + assert parent.filled == D("10") + assert {(c.venue, c.order_id) for c in parent.children} == {(c.venue, c.id) for c in children} + result = await restored.cancel(original.id) + assert result.status.value == "canceled" and result.filled == D("10") + assert not [order for paper in (kalshi, poly) for order in paper.orders.values() if not order.is_terminal] + assert len(kalshi.orders) == len(poly.orders) == 1 + for child in await journal.orders_for_parent(original.id): + assert child.account == {"kalshi": ACCOUNT_K, "polymarket": ACCOUNT_P}[child.venue] + assert (await journal.order(None, original.id)).filled == D("10") + assert await journal.order("kalshi", original.id) is None + finally: + await restored.stop() diff --git a/tests/test_router.py b/tests/test_router.py index 9ff4702..2ad1a2e 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -53,6 +53,35 @@ def test_fees_change_the_order(self): assert asks[1].net_price == D("0.43") +class TestDisabledMembers: + def test_a_member_switched_off_is_left_out_of_the_merge_and_the_plan(self): + b = Bucket(book="alpha", name="t", members=[BucketMember(market_id=K), + BucketMember(market_id=P, enabled=False)]) + books = {K: book(K, asks=[(0.45, 30)]), P: book(P, asks=[(0.41, 100)])} + asks, _ = merge(b, books, NO_FEE) + assert {l.market_id for l in asks} == {K} + out = plan(b, books, PREC, NO_FEE, side=Side.BUY, quantity=D("50"), limit=D("0.50")) + assert [(l.market_id, l.quantity) for l in out.legs] == [(K, D("30"))] + + def test_enabled_defaults_on_round_trips_and_routable_drops_the_off_members(self): + old = Bucket.model_validate({"book": "a", "name": "t", "members": [{"market_id": K}, {"market_id": P}]}) + assert all(m.enabled for m in old.members) and old.routable() is old + off = old.model_copy(update={"members": [BucketMember(market_id=K), BucketMember(market_id=P, enabled=False)]}) + again = Bucket.model_validate(off.model_dump(mode="json")) + assert [m.enabled for m in again.members] == [True, False] + assert again.routable().market_ids() == [K] and again.market_ids() == [K, P] + + def test_a_definition_needs_one_enabled_member(self): + none_on = Bucket(book="a", name="t", members=[BucketMember(market_id=K, enabled=False), + BucketMember(market_id=P, enabled=False)]) + with pytest.raises(BadRequest, match="enabled"): + none_on.check() + with pytest.raises(BadRequest, match="switched off"): + none_on.routable() + Bucket(book="a", name="t", members=[BucketMember(market_id=K, enabled=False), + BucketMember(market_id=P)]).check() + + class TestPlan: def test_walks_best_net_first_and_never_over_allocates(self): books = {K: book(K, asks=[(0.41, 30)]), P: book(P, asks=[(0.42, 100)])} diff --git a/tests/test_serve.py b/tests/test_serve.py index d5ca9be..d0d6b41 100644 --- a/tests/test_serve.py +++ b/tests/test_serve.py @@ -96,7 +96,7 @@ async def test_every_loop_the_stack_needs_is_scheduled(self, tmp_path): stack, *_ = await stack_for(tmp_path) loops = serve.background(stack) try: - assert {"lease", "sweep", "poll", "managed", "feeds-sync", "feed-kalshi-kalshi", + assert {"lease", "sweep", "poll", "managed", "dispatch", "feeds-sync", "feed-kalshi-kalshi", "reconcile", "control", "eod", "status"} <= set(loops) finally: for coro in loops.values(): @@ -113,13 +113,13 @@ async def test_streams_can_be_turned_off(self, tmp_path): coro.close() await serve.shutdown(stack) - async def test_shutdown_cancels_what_rests_and_releases_the_lease(self, tmp_path): + async def test_shutdown_preserves_what_rests_and_releases_the_lease(self, tmp_path): stack, kalshi, fake = await stack_for(tmp_path) async with httpx.AsyncClient(transport=httpx.ASGITransport(app=stack.app), base_url="http://s") as client: await client.post("/trading/orders", headers={"Authorization": f"Bearer {stack.owner_key}"}, json={"market_id": K, "side": "buy", "quantity": "5", "type": "limit", "price": "0.35"}) await serve.shutdown(stack) - assert all(o.is_terminal for o in kalshi.orders.values()), "the default halt policy cancels" + assert all(not o.is_terminal for o in kalshi.orders.values()), "a normal restart preserves orders" assert fake.closed again, *_ = await stack_for(tmp_path) # the lease is free: a new process can start await serve.shutdown(again) diff --git a/tests/test_serve_auth.py b/tests/test_serve_auth.py new file mode 100644 index 0000000..1e6f922 --- /dev/null +++ b/tests/test_serve_auth.py @@ -0,0 +1,84 @@ +"""`synpath serve` requires its access token on every route but `/`, `/health` and the docs.""" +from __future__ import annotations + +import pytest +from starlette.websockets import WebSocketDisconnect + +from serve_harness import serve_harness # noqa: F401 (the fixture) + +MARKET_ROUTES = [ + "/venues", + "/venues/kalshi", + "/venues/kalshi/markets", + "/venues/kalshi/markets/KXHARNESS-1", + "/venues/kalshi/events", + "/venues/kalshi/markets/KXHARNESS-1/book", + "/venues/kalshi/markets/KXHARNESS-1/trades", + "/venues/kalshi/markets/KXHARNESS-1/candles", + "/venues/kalshi/markets/KXHARNESS-1/fee", + "/venues/kalshi/series/KXHARNESS", + "/venues/nowhere/markets", +] + + +def test_every_market_data_route_refuses_a_caller_without_a_token(serve_harness): + with serve_harness() as harness: + for route in MARKET_ROUTES: + response = harness.client.get(route) + assert response.status_code == 401, route + assert response.json()["error"]["code"] == "unauthorized" + assert harness.client.get("/venues", headers={"Authorization": "Bearer sk_not_a_key"}).status_code == 401 + assert not harness.kalshi.requests, "an unauthenticated call never reaches the venue" + + +def test_with_the_token_market_data_is_served(serve_harness): + with serve_harness() as harness: + assert harness.client.get("/venues", headers=harness.auth).status_code == 200 + book = harness.client.get("/venues/kalshi/markets/KXHARNESS-1/book", headers=harness.auth) + assert book.status_code == 200, book.text + assert book.json()["bids"][0]["price"] == 0.4 + assert harness.client.get("/venues/kalshi/markets/KXHARNESS-1/book", + headers={"X-API-Key": harness.token}).status_code == 200 + assert harness.client.get("/venues/nowhere/markets", headers=harness.auth).status_code == 404 + + +def test_what_this_server_is_and_whether_it_is_up_stay_open(serve_harness): + with serve_harness() as harness: + assert harness.client.get("/health").status_code == 200 + index = harness.client.get("/") + assert index.status_code == 200 + assert index.json()["links"]["market_stream"] == "/ws/market" + assert harness.client.get("/openapi.json").status_code == 200 + + +def test_trading_still_requires_the_token(serve_harness): + with serve_harness() as harness: + assert harness.client.get("/trading/orders").status_code == 401 + assert harness.client.get("/trading/orders", headers=harness.auth).status_code == 200 + + +@pytest.mark.parametrize("credential", ["", "key=sk_not_a_key"]) +def test_the_market_socket_closes_4401_without_a_valid_token(serve_harness, credential): + with serve_harness() as harness: + with harness.client.websocket_connect(f"/ws/market?market_id=polymarket:1&{credential}") as socket: + with pytest.raises(WebSocketDisconnect) as closed: + socket.receive_json() + assert closed.value.code == 4401 + assert "polymarket" not in harness.opened, "nothing was subscribed upstream" + + +def test_the_market_socket_takes_the_token_as_a_header_or_a_query(serve_harness): + with serve_harness() as harness: + with harness.client.websocket_connect("/ws/market?market_id=polymarket:1", headers=harness.auth) as socket: + assert socket.receive_json()["type"] == "subscribed" + with harness.client.websocket_connect(f"/ws/market?market_id=polymarket:1&key={harness.token}") as socket: + assert socket.receive_json()["type"] == "subscribed" + + +def test_the_python_client_still_trades_through_the_server(serve_harness): + with serve_harness() as harness: + client = harness.remote_client() + try: + assert harness.call(client.fetch_open_orders) == [] + finally: + harness.call(client.close) diff --git a/tests/test_serve_batch.py b/tests/test_serve_batch.py new file mode 100644 index 0000000..3101593 --- /dev/null +++ b/tests/test_serve_batch.py @@ -0,0 +1,147 @@ +"""`synpath serve` batch reads and search: many ids or venues in one call, failures reported per id or venue.""" +from __future__ import annotations + +from typing import Any + +from synpath.errors import ExchangeNotAvailable, NotSupported +from synpath.types import Event, Market, OrderBook, OrderLevel, Outcome, Page, Quote + +from serve_harness import serve_harness # noqa: F401 (the fixture) + + +def market(venue: str, native: str, bid: float = 0.3) -> Market: + return Market(id=f"{venue}:{native}", venue=venue, venue_market_id=native, title=f"{venue} {native}", + yes=Outcome(label="Yes", quote=Quote(bid=bid, ask=bid + 0.05)), + no=Outcome(label="No"), status="open", active=True) + + +class StubPolymarket: + """Polymarket's read adapter, answering from memory and recording every call.""" + + id = "polymarket" + + def __init__(self, known: tuple[str, ...] = ("1", "2")): + self.known = known + self.calls: list[tuple[str, Any]] = [] + + def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]: + self.calls.append(("fetch_markets_by_ids", list(market_ids))) + return [market("polymarket", m.split(":")[1]) for m in market_ids if m.split(":")[1] in self.known] + + def fetch_order_books(self, market_ids: list[str], *, side: str = "yes", depth: int | None = None): + self.calls.append(("fetch_order_books", (list(market_ids), side, depth))) + return {m: OrderBook(market_id=m, side=side, venue="polymarket", bids=[OrderLevel(price=0.3, size=5)]) + for m in market_ids if m.split(":")[1] in self.known} + + def fetch_markets(self, *, query=None, limit=None, cursor=None, status="open", sort=None): + self.calls.append(("fetch_markets", (query, limit, cursor, status))) + return Page([market("polymarket", "1")], next_cursor="p2") + + def fetch_events(self, *, query=None, limit=None, cursor=None, status="open"): + self.calls.append(("fetch_events", (query, limit, cursor, status))) + return Page([Event(id="polymarket:e1", venue="polymarket", venue_event_id="e1", title="E1", status="open", + markets=[market("polymarket", "1")])], next_cursor=None) + + +class DownVenue: + """A venue that fails every call, as one in maintenance does.""" + + id = "opinion" + + def __getattr__(self, name: str): + def fail(*args: Any, **kwargs: Any) -> Any: + raise ExchangeNotAvailable("opinion: down for maintenance") + return fail + + +class NoBatchBooks(StubPolymarket): + id = "limitless" + + def fetch_order_books(self, *args: Any, **kwargs: Any): + raise NotSupported("limitless: fetch_order_books") + + def fetch_order_book(self, market_id: str, *, side: str = "yes", depth: int | None = None): + return OrderBook(market_id=market_id, side=side, venue="limitless") + + +def harness_with_stubs(serve_harness, **stubs): + return serve_harness(exchanges={"polymarket": StubPolymarket(), "opinion": DownVenue(), **stubs}) + + +def test_batch_markets_answer_every_id_in_data_or_errors(serve_harness): + with harness_with_stubs(serve_harness) as harness: + ids = "polymarket:2, kalshi:KXHARNESS-1,polymarket:1,polymarket:2,kalshi:GONE,opinion:9,nope,mars:1" + response = harness.client.get("/markets", params={"ids": ids}, headers=harness.auth) + assert response.status_code == 200, response.text + body = response.json() + assert [m["id"] for m in body["data"]] == ["polymarket:2", "kalshi:KXHARNESS-1", "polymarket:1"] + assert body["data"][1]["yes"]["quote"]["bid"] == 0.4, "the quote travels with the market" + errors = {market_id: error["code"] for market_id, error in body["errors"].items()} + assert errors == {"kalshi:GONE": "market_not_found", "opinion:9": "exchange_not_available", + "nope": "bad_market_id", "mars:1": "bad_market_id"} + assert body["errors"]["opinion:9"]["details"] == {"venue": "opinion", "retryable": True} + polymarket = harness.exchanges["polymarket"] + assert polymarket.calls == [("fetch_markets_by_ids", ["polymarket:2", "polymarket:1"])], "one call per venue" + + +def test_batch_reads_are_capped_and_need_the_token(serve_harness): + with harness_with_stubs(serve_harness) as harness: + too_many = ",".join(f"polymarket:{i}" for i in range(101)) + refused = harness.client.get("/markets", params={"ids": too_many}, headers=harness.auth) + assert refused.status_code == 400 and refused.json()["error"]["code"] == "too_many_ids" + assert harness.client.get("/markets", params={"ids": " , "}, headers=harness.auth).status_code == 400 + for route in ("/markets?ids=polymarket:1", "/books?ids=polymarket:1", "/search/markets?query=x", + "/search/events?query=x"): + assert harness.client.get(route).status_code == 401, route + assert not harness.exchanges["polymarket"].calls + + +def test_batch_books_by_id_with_a_fallback_for_venues_without_a_batch(serve_harness): + with harness_with_stubs(serve_harness, limitless=NoBatchBooks()) as harness: + response = harness.client.get( + "/books", params={"ids": "polymarket:1,polymarket:7,kalshi:KXHARNESS-1,limitless:a,opinion:3", + "side": "no", "depth": 5}, headers=harness.auth) + assert response.status_code == 200, response.text + body = response.json() + assert sorted(body["data"]) == ["kalshi:KXHARNESS-1", "limitless:a", "polymarket:1"] + assert body["data"]["kalshi:KXHARNESS-1"]["side"] == "no" and body["data"]["kalshi:KXHARNESS-1"]["derived"] + assert harness.exchanges["polymarket"].calls == [("fetch_order_books", (["polymarket:1", "polymarket:7"], "no", 5))] + assert {k: v["code"] for k, v in body["errors"].items()} == { + "polymarket:7": "market_not_found", "opinion:3": "exchange_not_available"} + + +def test_search_asks_each_venue_and_reports_failures_per_venue(serve_harness): + with harness_with_stubs(serve_harness) as harness: + response = harness.client.get("/search/markets", params=[("query", "btc"), ("venue", "polymarket"), + ("venue", "opinion"), ("limit", "5")], + headers=harness.auth) + assert response.status_code == 200, response.text + body = response.json() + assert list(body["results"]) == ["polymarket"] + assert body["results"]["polymarket"]["next_cursor"] == "p2" + assert body["results"]["polymarket"]["data"][0]["id"] == "polymarket:1" + assert body["errors"]["opinion"]["code"] == "exchange_not_available" + assert ("fetch_markets", ("btc", 5, None, "open")) in harness.exchanges["polymarket"].calls + + events = harness.client.get("/search/events", params={"query": "btc", "venue": "polymarket", "cursor": "c"}, + headers=harness.auth).json() + assert events["results"]["polymarket"]["data"][0]["markets"][0]["id"] == "polymarket:1" + assert ("fetch_events", ("btc", 20, "c", "open")) in harness.exchanges["polymarket"].calls + + +def test_search_refuses_a_cursor_across_venues_and_unknown_venues(serve_harness): + with harness_with_stubs(serve_harness) as harness: + across = harness.client.get("/search/markets", params=[("query", "x"), ("cursor", "c"), + ("venue", "polymarket"), ("venue", "kalshi")], + headers=harness.auth) + assert across.status_code == 400 + unknown = harness.client.get("/search/markets", params={"query": "x", "venue": "mars"}, headers=harness.auth) + assert unknown.status_code == 404 and unknown.json()["error"]["code"] == "unknown_venue" + assert harness.client.get("/search/markets", params={"query": ""}, headers=harness.auth).status_code == 422 + + +def test_candles_take_a_source_only_where_the_venue_has_one(serve_harness): + with serve_harness() as harness: + refused = harness.client.get("/venues/kalshi/markets/KXHARNESS-1/candles", params={"source": "quotes"}, + headers=harness.auth) + assert refused.status_code == 400 and refused.json()["error"]["details"]["field"] == "source" diff --git a/tests/test_serve_buckets.py b/tests/test_serve_buckets.py new file mode 100644 index 0000000..8a49335 --- /dev/null +++ b/tests/test_serve_buckets.py @@ -0,0 +1,395 @@ +"""Buckets through `synpath serve`: definitions over REST, the merged book on +`/ws/market`, and the router's preview, each against the library's own router.""" +from __future__ import annotations + +import uuid +from decimal import Decimal as D +from typing import Any + +from synpath.bucket import Bucket, BucketMember +from synpath.engine import router +from synpath.engine.paper import PaperVenue +from synpath.trading.types import Account, Side +from synpath.types import FeeSchedule, OrderBook, OrderLevel + +from serve_harness import receive_until, serve_harness # noqa: F401 (the fixture) + +K, P, P2 = "kalshi:KXHARNESS-1", "polymarket:1", "polymarket:2" +POLY_THETA = 0.02 + + +class StubPolymarket: + """Polymarket's read adapter for buckets: a published fee schedule and a + book, from memory. Every call is recorded.""" + + id = "polymarket" + + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + def fetch_fee_schedule(self, market_id: str) -> FeeSchedule: + self.calls.append(("fee", market_id)) + return FeeSchedule(venue="polymarket", scope="market", scope_id=market_id, fee_type="quadratic_theta", + taker_rate=POLY_THETA, maker_rate=0.0) + + def fetch_order_book(self, market_id: str, *, side: str = "yes", depth: int | None = None) -> OrderBook: + self.calls.append(("book", market_id)) + return OrderBook(market_id=market_id, side=side, venue="polymarket", + bids=[OrderLevel(price=0.38, size=20)], asks=[OrderLevel(price=0.44, size=20)]) + + def close(self) -> None: + pass + + +def bucket_harness(serve_harness, **kwargs: Any): + poly = PaperVenue(venue="polymarket", cash=D("10000"), account=Account(venue="polymarket", name="paper")) + return serve_harness(trading={"polymarket": poly}, exchanges={"polymarket": StubPolymarket()}, **kwargs) + + +def definition(**extra: Any) -> dict[str, Any]: + return {"book": "terminal", "name": "Same question", + "members": [{"market_id": K}, {"market_id": P, "flip": True}], **extra} + + +def create(harness, **extra: Any) -> dict[str, Any]: + made = harness.client.post("/trading/buckets", json=definition(**extra), headers=harness.auth) + assert made.status_code == 201, made.text + return made.json() + + +def market_url(harness, *markets: str, channels: str = "book", **extra: str) -> str: + query = "&".join([f"key={harness.token}", f"channels={channels}", *(f"market_id={m}" for m in markets), + *(f"{k}={v}" for k, v in extra.items())]) + return f"/ws/market?{query}" + + +def stored_bucket(raw: dict[str, Any]) -> Bucket: + return Bucket.model_validate(raw) + + +def expected_levels(harness, bucket: Bucket) -> tuple[list[tuple], list[tuple]]: + """The library's own merge of the hub's books, with serve's fee cache.""" + def merge() -> tuple[list[tuple], list[tuple]]: + hub = harness.stack.market_streams + views = {m.market_id: router.view(hub.member_book(m.market_id), m) for m in bucket.members} + fees = hub.buckets.fees + asks, _ = router.merge(bucket, views, fees.fee_function(bucket, Side.BUY)) + _, bids = router.merge(bucket, views, fees.fee_function(bucket, Side.SELL)) + row = lambda l: (str(l.price), str(l.net_price), str(l.size), l.market_id) # noqa: E731 + return [row(l) for l in asks], [row(l) for l in bids] + return harness.call(merge) + + +def rows(message: dict[str, Any], side: str) -> list[tuple]: + return [(r["price"], r["net_price"], r["size"], r["market_id"]) for r in message[side]] + + +def book_where(socket: Any, test: Any, limit: int = 20) -> dict[str, Any]: + """The next `bucket_book` frame that passes `test`, skipping older ones.""" + for _ in range(limit): + message = receive_until(socket, "bucket_book") + if test(message): + return message + raise AssertionError("no matching bucket_book frame") + + +def feed_both_members(harness) -> None: + kalshi, poly = harness.stream("kalshi"), harness.stream("polymarket") + harness.push("kalshi", kalshi.snapshot(K, bids=[("0.40", "10")], asks=[("0.45", "5"), ("0.47", "8")])) + harness.push("polymarket", poly.snapshot(P, bids=[("0.52", "6"), ("0.50", "9")], asks=[("0.58", "4")])) + + +# --------------------------------------------------------------------------- +# Definitions +# --------------------------------------------------------------------------- + +class TestBucketDefinitions: + def test_create_list_get_rename_edit_archive_and_restore(self, serve_harness): + with bucket_harness(serve_harness) as harness: + client, auth = harness.client, harness.auth + bucket = create(harness) + assert len(bucket["id"]) == 32 and bucket["book"] == "terminal" + create(harness, book="other") + listed = client.get("/trading/buckets?book=terminal", headers=auth).json() + assert [b["id"] for b in listed["data"]] == [bucket["id"]], "listed by book" + assert client.get(f"/trading/buckets/bucket:{bucket['id']}", headers=auth).json()["name"] == "Same question" + + renamed = client.patch(f"/trading/buckets/{bucket['id']}", json={"name": "Renamed"}, headers=auth) + assert renamed.status_code == 200, renamed.text + assert renamed.json()["name"] == "Renamed" and renamed.json()["members"] == bucket["members"] + edited = client.patch(f"/trading/buckets/{bucket['id']}", headers=auth, json={ + "members": [{"market_id": K, "flip": True}, {"market_id": P}, {"market_id": P2, "flip": True}]}) + assert [(m["market_id"], m["flip"]) for m in edited.json()["members"]] == [(K, True), (P, False), (P2, True)] + assert edited.json()["created_ms"] == bucket["created_ms"] + + archived = client.delete(f"/trading/buckets/{bucket['id']}", headers=auth).json() + assert archived["status"] == "archived" + assert client.get("/trading/buckets?book=terminal", headers=auth).json()["count"] == 0 + restored = client.patch(f"/trading/buckets/{bucket['id']}", json={"status": "active"}, headers=auth) + assert restored.json()["status"] == "active" and restored.json()["name"] == "Renamed" + + def test_a_client_made_id_is_kept_and_taken_twice_is_409(self, serve_harness): + with bucket_harness(serve_harness) as harness: + dashed = str(uuid.uuid4()) + made = create(harness, id=dashed.upper()) + assert made["id"] == dashed, "kept as given, in lower case" + again = harness.client.post("/trading/buckets", json=definition(id=dashed), headers=harness.auth) + assert again.status_code == 409 + bad = harness.client.post("/trading/buckets", json=definition(id="not-a-uuid"), headers=harness.auth) + assert bad.status_code == 400 + + def test_edits_pass_the_same_checks_as_a_new_definition(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = create(harness) + one = harness.client.patch(f"/trading/buckets/{bucket['id']}", json={"members": [{"market_id": K}]}, + headers=harness.auth) + assert one.status_code == 400 and "two members" in one.text + untraded = harness.client.patch(f"/trading/buckets/{bucket['id']}", headers=harness.auth, json={ + "members": [{"market_id": K}, {"market_id": "polymarket_us:abc"}]}) + assert untraded.status_code == 400 and "polymarket_us" in untraded.text + assert harness.client.patch("/trading/buckets/nope", json={"name": "x"}, headers=harness.auth).status_code == 404 + + def test_every_bucket_route_needs_the_token(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = create(harness) + path = f"/trading/buckets/{bucket['id']}" + assert harness.client.post("/trading/buckets", json=definition()).status_code == 401 + assert harness.client.get("/trading/buckets").status_code == 401 + assert harness.client.get(path).status_code == 401 + assert harness.client.patch(path, json={"name": "x"}).status_code == 401 + assert harness.client.delete(path).status_code == 401 + plan = {"side": "buy", "quantity": "5", "worst_price": "0.6"} + assert harness.client.post(f"{path}/plan", json=plan).status_code == 401 + assert harness.client.post(f"{path}/plan", json=plan, headers={"Authorization": "Bearer wrong"}).status_code == 401 + + +# --------------------------------------------------------------------------- +# The merged book +# --------------------------------------------------------------------------- + +class TestMergedBook: + def test_the_merged_book_is_the_routers_in_bucket_terms_net_of_fees(self, serve_harness): + with bucket_harness(serve_harness) as harness: + raw = create(harness) + bucket = stored_bucket(raw) + with harness.client.websocket_connect(market_url(harness, bucket.market_id, channels="book,trades,quotes")) as socket: + subscribed = receive_until(socket, "subscribed") + assert subscribed["venue"] == "bucket" and subscribed["channels"] == ["book", "trades"] + assert subscribed["unsupported"] == ["quotes"] and subscribed["bucket"]["id"] == bucket.id + waiting = receive_until(socket, "bucket_book") + assert waiting["ready"] is False and waiting["asks"] == [] + + feed_both_members(harness) + book = receive_until(socket, "bucket_book", ready=True) + expected_asks, expected_bids = expected_levels(harness, bucket) + assert rows(book, "asks") == expected_asks and rows(book, "bids") == expected_bids + # The flipped Polymarket member is read through its NO side: + # its YES bid at 0.52 is a bucket ask at 0.48. + assert ("0.48", P) in [(r["price"], r["market_id"]) for r in book["asks"]] + assert {r["venue"] for r in book["asks"]} == {"kalshi", "polymarket"} + # Net of fees: Kalshi's quadratic fee at 0.45 is $1.74 per 100 contracts (rounded up to the cent). + kalshi_ask = next(r for r in book["asks"] if r["market_id"] == K and r["price"] == "0.45") + assert D(kalshi_ask["net_price"]) == D("0.4674") + assert book["best_ask"] == "0.45" and book["best_bid"] == "0.42" + assert book["fees_unknown"] == [] and all(m["ready"] for m in book["members"]) + + harness.push("kalshi", harness.stream("kalshi").delta(K, "ask", "0.41", "3")) + updated = receive_until(socket, "bucket_book", ready=True, best_ask="0.41") + assert rows(updated, "asks") == expected_levels(harness, bucket)[0] + + def test_a_member_dropping_makes_the_bucket_not_ready_until_it_is_back(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = stored_bucket(create(harness)) + with harness.client.websocket_connect(market_url(harness, bucket.market_id)) as socket: + receive_until(socket, "subscribed") + feed_both_members(harness) + receive_until(socket, "bucket_book", ready=True) + poly = harness.stream("polymarket") + harness.push("polymarket", poly.drop()) + assert receive_until(socket, "status")["state"] == "disconnected" + dropped = receive_until(socket, "bucket_book") + assert dropped["ready"] is False and dropped["asks"] == [] and dropped["bids"] == [] + assert [m["ready"] for m in dropped["members"]] == [True, False] + harness.push("polymarket", poly.reconnected()) + harness.push("polymarket", poly.snapshot(P, bids=[("0.52", "6")], asks=[("0.58", "4")])) + assert receive_until(socket, "bucket_book", ready=True)["ready"] + + def test_member_trades_arrive_in_bucket_terms(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = stored_bucket(create(harness)) + with harness.client.websocket_connect(market_url(harness, bucket.market_id, channels="book,trades")) as socket: + receive_until(socket, "subscribed") + harness.wait_until(lambda: "polymarket" in harness.opened) + harness.push("polymarket", harness.stream("polymarket").trade(P, "0.42", "4")) + trade = receive_until(socket, "trade") + assert trade["market_id"] == bucket.market_id and trade["member_market_id"] == P + assert trade["price"] == "0.58" and trade["flip"] is True and trade["quantity"] == "4" + + def test_a_bucket_shares_the_venue_connections_and_releases_them(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = stored_bucket(create(harness)) + with harness.client.websocket_connect(market_url(harness, bucket.market_id, P)) as socket: + receive_until(socket, "subscribed", market_id=bucket.market_id) + receive_until(socket, "subscribed", market_id=P) + assert len(harness.opened["polymarket"]) == 1, "one Polymarket connection for both" + stream = harness.stream("polymarket") + assert stream.watched == [("book", (P,))], "subscribed upstream once" + socket.send_json({"op": "unsubscribe", "market_id": bucket.market_id}) + receive_until(socket, "unsubscribed") + assert stream.unwatched == [], "the direct subscription still holds the market" + harness.wait_until(lambda: "polymarket" not in harness.stack.market_streams.connections) + assert stream.closed and harness.stack.market_streams.bucket_subscriptions == {} + + def test_an_edit_moves_a_streaming_client_to_the_new_definition(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = stored_bucket(create(harness)) + with harness.client.websocket_connect(market_url(harness, bucket.market_id)) as socket: + receive_until(socket, "subscribed") + feed_both_members(harness) + receive_until(socket, "bucket_book", ready=True) + harness.client.patch(f"/trading/buckets/{bucket.id}", headers=harness.auth, json={ + "name": "Edited", "members": [{"market_id": K}, {"market_id": P2, "flip": True}]}) + edited = receive_until(socket, "bucket_book", name="Edited") + assert [m["market_id"] for m in edited["members"]] == [K, P2] and edited["ready"] is False + stream = harness.stream("polymarket") + harness.wait_until(lambda: P in stream.unwatched) + harness.push("polymarket", stream.snapshot(P2, bids=[("0.55", "2")], asks=[("0.60", "2")])) + ready = receive_until(socket, "bucket_book", ready=True) + assert {r["market_id"] for r in ready["asks"]} == {K, P2} + + def test_unknown_hidden_and_badly_asked_buckets_are_refused_per_subscription(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = stored_bucket(create(harness)) + viewer_key = harness.call(_kalshi_only_key, harness) + with harness.client.websocket_connect(f"/ws/market?key={harness.token}") as socket: + socket.send_json({"op": "subscribe", "market_id": "bucket:nope"}) + assert receive_until(socket, "error")["code"] == "bucket_not_found" + socket.send_json({"op": "subscribe", "market_id": bucket.market_id, "side": "no"}) + assert receive_until(socket, "error")["code"] == "bad_request" + with harness.client.websocket_connect(f"/ws/market?key={viewer_key}") as socket: + socket.send_json({"op": "subscribe", "market_id": bucket.market_id}) + assert receive_until(socket, "error")["code"] == "forbidden" + with harness.client.websocket_connect(f"/ws/market?market_id={bucket.market_id}") as socket: + assert socket.receive()["code"] == 4401 + + def test_a_member_switched_off_is_not_streamed_merged_or_waited_for_and_patch_toggles_it(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = stored_bucket(create(harness)) + with harness.client.websocket_connect(market_url(harness, bucket.market_id)) as socket: + receive_until(socket, "subscribed") + feed_both_members(harness) + receive_until(socket, "bucket_book", ready=True) + stream = harness.stream("polymarket") + + off = harness.client.patch(f"/trading/buckets/{bucket.id}", headers=harness.auth, json={ + "members": [{"market_id": K}, {"market_id": P, "flip": True, "enabled": False}]}) + assert off.status_code == 200, off.text + assert [m["enabled"] for m in off.json()["members"]] == [True, False] + harness.wait_until(lambda: P in stream.unwatched) + only_k = book_where(socket, lambda m: m["ready"] and [x["enabled"] for x in m["members"]] == [True, False]) + assert {r["market_id"] for r in only_k["asks"] + only_k["bids"]} == {K} + assert [(m["market_id"], m["enabled"]) for m in only_k["members"]] == [(K, True), (P, False)] + routable = stored_bucket(off.json()).routable() + assert rows(only_k, "asks") == expected_levels(harness, routable)[0] + + plan = harness.client.post(f"/trading/buckets/{bucket.id}/plan", headers=harness.auth, json={ + "side": "buy", "quantity": "10", "worst_price": "0.70"}).json() + assert {l["market_id"] for l in plan["legs"]} == {K} and plan["books"] == {K: "stream"} + + on = harness.client.patch(f"/trading/buckets/{bucket.id}", headers=harness.auth, json={ + "members": [{"market_id": K}, {"market_id": P, "flip": True}]}) + assert [m["enabled"] for m in on.json()["members"]] == [True, True] + # Switched back on: subscribed again (on a fresh connection, if the + # old one closed once nothing on it was wanted). + harness.wait_until(lambda: harness.stream("polymarket").watched.count(("book", (P,))) + == (2 if harness.stream("polymarket") is stream else 1)) + stream = harness.stream("polymarket") + book_where(socket, lambda m: not m["ready"] and [x["enabled"] for x in m["members"]] == [True, True]) + harness.push("polymarket", stream.snapshot(P, bids=[("0.52", "6")], asks=[("0.58", "4")])) + both = book_where(socket, lambda m: m["ready"] and [x["enabled"] for x in m["members"]] == [True, True]) + assert {r["market_id"] for r in both["asks"]} == {K, P} + + def test_every_member_switched_off_is_refused(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = create(harness) + none_on = harness.client.patch(f"/trading/buckets/{bucket['id']}", headers=harness.auth, json={ + "members": [{"market_id": K, "enabled": False}, {"market_id": P, "enabled": False}]}) + assert none_on.status_code == 400 and "enabled" in none_on.text + made = harness.client.post("/trading/buckets", headers=harness.auth, json=definition(members=[ + {"market_id": K, "enabled": False}, {"market_id": P, "enabled": False}])) + assert made.status_code == 400 + assert all(m["enabled"] for m in bucket["members"]), "members default to enabled" + + +async def _kalshi_only_key(harness) -> str: + store = harness.stack.store + user = await store.create_user("kalshi-viewer") + await store.grant(user.id, harness.stack.engine.accounts["kalshi"].key, "view") + return (await store.issue_key(user.id)).secret + + +# --------------------------------------------------------------------------- +# The router preview +# --------------------------------------------------------------------------- + +def library_plan(harness, bucket: Bucket, side: Side, quantity: str, limit: str) -> router.Plan: + """`router.plan` called directly on the hub's books with serve's fees.""" + def make() -> router.Plan: + hub = harness.stack.market_streams + fees = hub.buckets.fees + books = {m.market_id: hub.member_book(m.market_id) for m in bucket.members} + precision = {m.market_id: router.default_precision(m.market_id) for m in bucket.members} + return router.plan(bucket, books, precision, fees.fee_function(bucket, side), side=side, + quantity=D(quantity), limit=D(limit), floors=fees.floors(bucket)) + return harness.call(make) + + +class TestPlanPreview: + def test_the_preview_is_the_library_plan_on_the_live_books(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = stored_bucket(create(harness)) + with harness.client.websocket_connect(market_url(harness, bucket.market_id)) as socket: + receive_until(socket, "subscribed") + feed_both_members(harness) + receive_until(socket, "bucket_book", ready=True) + for side, quantity, worst in ((Side.BUY, "12", "0.50"), (Side.SELL, "10", "0.30"), + (Side.BUY, "100", "0.55")): + answer = harness.client.post(f"/trading/buckets/{bucket.id}/plan", headers=harness.auth, json={ + "side": side.value, "quantity": quantity, "worst_price": worst}) + assert answer.status_code == 200, answer.text + preview = answer.json() + expected = library_plan(harness, bucket, side, quantity, worst) + assert [(l["market_id"], l["side"], D(l["price"]), D(l["quantity"]), D(l["net_price"]), D(l["fee"])) + for l in preview["legs"]] == \ + [(l.market_id, l.side.value, l.price, l.quantity, l.net_price, l.fee) for l in expected.legs] + assert D(preview["unfilled"]) == expected.unfilled + assert preview["stop_reason"] == (expected.reason or None) + assert D(preview["allocated"]) == expected.allocated <= D(quantity) + assert preview["books"] == {K: "stream", P: "stream"} + big = harness.client.post(f"/trading/buckets/{bucket.id}/plan", headers=harness.auth, json={ + "side": "buy", "quantity": "100", "worst_price": "0.55"}).json() + assert big["stop_reason"] in ("worst_price", "liquidity") and D(big["unfilled"]) > 0 + flipped_leg = next(l for l in big["legs"] if l["market_id"] == P) + assert flipped_leg["flip"] and flipped_leg["side"] == "sell", "buying a flipped member's NO is a sell" + assert harness.stack.engine.open_orders() == [], "a preview places nothing" + + def test_without_a_stream_the_preview_reads_the_books_from_the_venues(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = stored_bucket(create(harness)) + answer = harness.client.post(f"/trading/buckets/bucket:{bucket.id}/plan", headers=harness.auth, + json={"side": "buy", "quantity": "3", "worst_price": "0.70"}) + assert answer.status_code == 200, answer.text + preview = answer.json() + assert preview["books"] == {K: "rest", P: "rest"} + assert sum(D(l["quantity"]) for l in preview["legs"]) == D("3") and preview["stop_reason"] is None + + def test_a_bad_preview_request_is_refused(self, serve_harness): + with bucket_harness(serve_harness) as harness: + bucket = stored_bucket(create(harness)) + path = f"/trading/buckets/{bucket.id}/plan" + assert harness.client.post(path, headers=harness.auth, json={ + "side": "buy", "quantity": "0", "worst_price": "0.5"}).status_code in (400, 422) + assert harness.client.post(path, headers=harness.auth, json={ + "side": "buy", "quantity": "1", "worst_price": "1.5"}).status_code in (400, 422) + assert harness.client.post("/trading/buckets/nope/plan", headers=harness.auth, json={ + "side": "buy", "quantity": "1", "worst_price": "0.5"}).status_code == 404 diff --git a/tests/test_serve_hosted.py b/tests/test_serve_hosted.py new file mode 100644 index 0000000..73481b8 --- /dev/null +++ b/tests/test_serve_hosted.py @@ -0,0 +1,89 @@ +"""`synpath serve` matching and tick history: serve calls Synpath's hosted services with its own key.""" +from __future__ import annotations + +import pytest + +from serve_harness import MockHosted, serve_harness # noqa: F401 (the fixture) + +META = {"dataset_version": "processed-v1-test"} +MATCH = {"anchor": "polymarket:1", "event_id": "ev-1", + "matched": {"id": "kalshi:KX-A", "venue": "kalshi", "side_map": {"yes": "no", "no": "yes"}}} +BOOK_AT = {"metadata": META, "market_id": "kalshi:KX-A", "as_of_ms": 5, "book": None, "absence_reason": "no coverage"} + + +@pytest.fixture(autouse=True) +def serve_key(monkeypatch, tmp_path): + monkeypatch.setenv("SYNPATH_API_KEY", "spk_serve") + monkeypatch.delenv("SYNPATH_MATCHING_URL", raising=False) + monkeypatch.delenv("SYNPATH_HISTORY_URL", raising=False) + monkeypatch.setenv("SYNPATH_CREDENTIALS_FILE", str(tmp_path / "no-credentials.json")) + + +def test_matching_goes_through_serve_with_serves_key(serve_harness): + hosted = MockHosted({("GET", "/match/market"): MATCH, + ("GET", "/match/event"): {"anchor": "polymarket:1", "event_ids": ["ev-1"], + "events": {"kalshi": ["KX"], "opinion": None}}, + ("GET", "/arbitrage"): {"computed_at": "2026-10-08T10:00:00+00:00", "results": []}}) + with serve_harness(hosted=hosted) as harness: + matched = harness.client.get("/matching/market", params={"id": "polymarket:1", "venue": "kalshi"}, + headers=harness.auth) + assert matched.status_code == 200, matched.text + assert matched.json()["matched"]["side_map"] == {"yes": "no", "no": "yes"} + event = harness.client.get("/matching/event", params={"id": "polymarket:1"}, headers=harness.auth).json() + assert event["events"] == {"kalshi": ["KX"], "opinion": None} + scan = harness.client.get("/matching/arbitrage", headers=harness.auth).json() + assert scan["computed_at"].startswith("2026-10-08") + first = hosted.requests[0] + assert str(first.url) == "https://api.synpath.dev/match/market?id=polymarket%3A1&venue=kalshi" + assert {r.headers["authorization"] for r in hosted.requests} == {"Bearer spk_serve"} + assert all("spk_serve" not in str(r.url) for r in hosted.requests) + + +def test_history_routes_reach_the_history_service(serve_harness): + trades = {"metadata": META, "market_id": "kalshi:KX-A", "start_ms": 1, "end_ms": 9, "trades": [], "coverage": []} + book_range = {"metadata": META, "market_id": "kalshi:KX-A", "start_ms": 1, "end_ms": 9, "segments": []} + hosted = MockHosted({("POST", "/v1/order-book/at"): BOOK_AT, ("POST", "/v1/order-book/range"): book_range, + ("POST", "/v1/trades/range"): trades}) + with serve_harness(hosted=hosted) as harness: + at = harness.client.get("/history/order-book/at", params={"market_id": "kalshi:KX-A", "as_of_ms": 5, + "depth": 3}, headers=harness.auth) + assert at.status_code == 200, at.text + assert at.json()["absence_reason"] == "no coverage" + assert harness.client.get("/history/order-book/range", params={"market_id": "kalshi:KX-A", "start_ms": 1, + "end_ms": 9}, headers=harness.auth).status_code == 200 + assert harness.client.get("/history/trades/range", params={"market_id": "kalshi:KX-A", "start_ms": 1, + "end_ms": 9, "limit": 10}, headers=harness.auth).status_code == 200 + assert [str(r.url) for r in hosted.requests][0] == "https://api2.synpath.dev/v1/order-book/at" + assert hosted.requests[0].content == b'{"market_id":"kalshi:KX-A","as_of_ms":5,"depth":3}' + backwards = harness.client.get("/history/trades/range", params={"market_id": "kalshi:KX-A", "start_ms": 9, + "end_ms": 1}, headers=harness.auth) + assert backwards.status_code == 400 and len(hosted.requests) == 3 + + +@pytest.mark.parametrize("answer, status, code", [ + ((401, {"error": "A valid API key is required"}), 502, "hosted_unauthorized"), + ((413, {"detail": "result_too_large"}), 413, "result_too_large"), + ((404, {"detail": "unknown market"}), 404, "market_not_found"), + (None, 502, "hosted_unavailable"), # the host in front answers 404 with no Synpath body +]) +def test_hosted_failures_get_their_own_codes(serve_harness, answer, status, code): + routes = {("POST", "/v1/order-book/at"): answer} if answer else {} + with serve_harness(hosted=MockHosted(routes)) as harness: + response = harness.client.get("/history/order-book/at", params={"market_id": "kalshi:KX-A", "as_of_ms": 5}, + headers=harness.auth) + assert response.status_code == status, response.text + error = response.json()["error"] + assert error["code"] == code and error["details"]["venue"] == "synpath" + + +def test_hosted_routes_need_serves_token_and_a_synpath_id(serve_harness): + hosted = MockHosted({("GET", "/match/market"): MATCH}) + with serve_harness(hosted=hosted) as harness: + for route in ("/matching/market?id=polymarket:1", "/matching/event?id=polymarket:1", "/matching/arbitrage", + "/history/order-book/at?market_id=kalshi:A&as_of_ms=1", + "/history/order-book/range?market_id=kalshi:A&start_ms=1&end_ms=2", + "/history/trades/range?market_id=kalshi:A&start_ms=1&end_ms=2"): + assert harness.client.get(route).status_code == 401, route + bad = harness.client.get("/matching/market", params={"id": "KX-A"}, headers=harness.auth) + assert bad.status_code == 400 and bad.json()["error"]["code"] == "bad_request" + assert not hosted.requests diff --git a/tests/test_serve_market_orders.py b/tests/test_serve_market_orders.py new file mode 100644 index 0000000..5d3d665 --- /dev/null +++ b/tests/test_serve_market_orders.py @@ -0,0 +1,113 @@ +"""Market orders through a whole `synpath serve`, on the mocked Kalshi, on SQLite and PostgreSQL. + +The reference pattern for an order type (lifecycle doc, instruction table, +save-before-send, offline tests on both stores) applied to `market`: Kalshi +has no market order type, so a market order is one immediate-or-cancel limit +at the worst price the caller gives (the terminal gives Kalshi's legal bound, +0.99 for a YES bid and 0.01 for a YES ask). The instruction is in +`market_orders` before the venue is called; a retry under the same +`client_order_id` never sends twice; an unanswered send is in doubt and is +adopted, not resent. See `synpath/engine/orders/lifecycle/market.md`. +""" +from __future__ import annotations + +import json +from decimal import Decimal as D + +from serve_harness import MockKalshi, serve_harness # noqa: F401 (the fixture) + +K = "kalshi:KXHARNESS-1" + + +def market(**changes) -> dict: + return {"market_id": K, "side": "buy", "quantity": "2", "type": "market", "price": "0.99", + "time_in_force": "ioc", **changes} + + +def place(harness, body: dict): + return harness.client.post("/trading/orders", headers=harness.auth, json=body) + + +def market_rows(harness) -> list[dict]: + """`market_orders` as stored, on whichever backend the harness runs.""" + journal = harness.stack.engine.journal + + async def read() -> list[dict]: + async with journal._db.execute( + "SELECT client_order_id, side, price, state, time_in_force, request, snapshot " + "FROM market_orders ORDER BY created_ts") as cursor: + return [dict(row) for row in await cursor.fetchall()] + + return harness.call(read) + + +class TestMarketOrders: + def test_a_market_order_is_saved_then_sent_once_as_ioc_at_its_worst_price_and_closes(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + placed = place(harness, market(client_order_id="mkt-1", tags={"terminal_outcome": "yes"})) + assert placed.status_code == 201, placed.text + order = placed.json() + assert (order["type"], order["status"], D(order["filled"])) == ("market", "closed", D(2)) + assert order["tags"]["terminal_outcome"] == "yes" + [sent] = kalshi.creates + assert (sent["side"], D(sent["price"]), sent["time_in_force"]) == ("bid", D("0.99"), "immediate_or_cancel") + [row] = market_rows(harness) + assert (row["client_order_id"], row["side"], D(row["price"])) == ("mkt-1", "buy", D("0.99")) + assert json.loads(row["request"])["price"] == "0.99", "the instruction is kept as it was made" + assert row["state"] == "closed" + listed = harness.client.get("/trading/orders", headers=harness.auth, params={"scope": "all"}).json() + assert [(o["id"], o["type"], o["tags"].get("terminal_outcome")) for o in listed["data"]] == \ + [(order["id"], "market", "yes")] + + def test_a_sell_yes_at_the_lowest_legal_price_keeps_its_outcome_and_action(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + placed = place(harness, market(side="sell", outcome="yes", action="sell", price="0.01", + client_order_id="mkt-s")) + assert placed.status_code == 201, placed.text + assert (placed.json()["outcome"], placed.json()["action"]) == ("yes", "sell") + [sent] = kalshi.creates + assert (sent["side"], D(sent["price"])) == ("ask", D("0.01")) and "reduce_only" not in sent + [row] = market_rows(harness) + assert row["side"] == "sell" and json.loads(row["request"])["action"] == "sell" + refused = place(harness, market(side="sell", price="0.01", reduce_only=True, client_order_id="mkt-r")) + assert refused.status_code in (400, 422) and "reduce_only is removed" in refused.text + + def test_the_same_client_order_id_twice_sends_once(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + first = place(harness, market(client_order_id="mkt-2")).json() + again = place(harness, market(client_order_id="mkt-2")).json() + assert first["id"] == again["id"] + assert len(kalshi.creates) == 1 + assert len(market_rows(harness)) == 1 + + def test_a_lost_response_is_in_doubt_and_the_retry_adopts_without_resending(self, serve_harness): + kalshi = MockKalshi(lose_create_response=True) + with serve_harness(kalshi=kalshi) as harness: + doubt = place(harness, market(client_order_id="mkt-3")) + assert doubt.status_code == 202, doubt.text + assert doubt.json()["error"]["code"] == "submission_in_doubt" + [row] = market_rows(harness) + assert row["client_order_id"] == "mkt-3", "saved before the send" + kalshi.lose_create_response = False + adopted = place(harness, market(client_order_id="mkt-3")) + assert adopted.status_code == 201, adopted.text + assert adopted.json()["status"] == "closed" + assert len(kalshi.creates) == 1, "checked with Kalshi, never sent twice" + + def test_another_instruction_under_the_same_id_is_refused(self, serve_harness): + kalshi = MockKalshi(lose_create_response=True) + with serve_harness(kalshi=kalshi) as harness: + place(harness, market(client_order_id="mkt-4")) + refused = place(harness, market(client_order_id="mkt-4", quantity="3")) + assert refused.status_code in (400, 409, 422), refused.text + assert len(kalshi.creates) == 1 + + def test_kalshi_refuses_a_market_order_without_a_worst_price_and_sends_nothing(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + refused = place(harness, market(price=None, client_order_id="mkt-5")) + assert 400 <= refused.status_code < 500, refused.text + assert kalshi.creates == [] diff --git a/tests/test_serve_market_stream.py b/tests/test_serve_market_stream.py new file mode 100644 index 0000000..9c7d3cb --- /dev/null +++ b/tests/test_serve_market_stream.py @@ -0,0 +1,214 @@ +"""`/ws/market`: live books, trades and quotes over shared, reference-counted venue connections.""" +from __future__ import annotations + +import pytest + +from serve_harness import receive_until, serve_harness # noqa: F401 (the fixture) + +P, P2, K = "polymarket:1", "polymarket:2", "kalshi:KXHARNESS-1" + + +def url(harness, *markets: str, channels: str = "book", **extra: str) -> str: + query = "&".join([f"key={harness.token}", f"channels={channels}", *(f"market_id={m}" for m in markets), + *(f"{k}={v}" for k, v in extra.items())]) + return f"/ws/market?{query}" + + +def levels(message, side): + return [(level["price"], level["size"]) for level in message[side]] + + +def hub_has_no_connection(harness, venue): + return lambda: venue not in harness.stack.market_streams.connections + + +class TestSubscribing: + def test_a_subscriber_gets_the_book_then_every_update(self, serve_harness): + with serve_harness() as harness: + with harness.client.websocket_connect(url(harness, P, channels="book,trades,quotes")) as socket: + subscribed = socket.receive_json() + assert subscribed == {"type": "subscribed", "venue": "polymarket", "market_id": P, "side": "yes", + "channels": ["book", "quotes", "trades"], "unsupported": []} + waiting = receive_until(socket, "book") + assert waiting["ready"] is False and waiting["bids"] == [], "no snapshot yet: no levels" + + stream = harness.stream("polymarket") + harness.push("polymarket", stream.snapshot(P, bids=[("0.40", "10"), ("0.39", "5")], asks=[("0.45", "3")])) + book = receive_until(socket, "book") + assert book["ready"] and levels(book, "bids") == [("0.40", "10"), ("0.39", "5")] + assert levels(book, "asks") == [("0.45", "3")] and book["best_bid"] == "0.40" + + harness.push("polymarket", stream.delta(P, "bid", "0.41", "2")) + assert levels(receive_until(socket, "book"), "bids")[0] == ("0.41", "2") + + harness.push("polymarket", stream.trade(P, "0.42", "4")) + trade = receive_until(socket, "trade") + assert (trade["market_id"], trade["price"], trade["quantity"]) == (P, "0.42", "4") + + harness.push("polymarket", stream.quote(P, "0.41", "0.45", side="no")) + harness.push("polymarket", stream.quote(P, "0.41", "0.45")) + quote = receive_until(socket, "quote") + assert quote["side"] == "yes" and quote["bid"] == "0.41", "only the subscribed side's quotes" + assert [kind for kind, _ in stream.watched] == ["book", "quotes", "trades"] + + def test_subscribe_and_unsubscribe_as_messages_with_depth_and_side(self, serve_harness): + with serve_harness() as harness: + with harness.client.websocket_connect(f"/ws/market?key={harness.token}") as socket: + socket.send_json({"op": "subscribe", "market_id": P, "side": "no", "depth": 1}) + assert receive_until(socket, "subscribed")["side"] == "no" + stream = harness.stream("polymarket") + harness.push("polymarket", stream.snapshot(P, bids=[("0.40", "10"), ("0.39", "5")], asks=[("0.45", "3")])) + book = receive_until(socket, "book", ready=True) + assert book["side"] == "no" and levels(book, "bids") == [("0.55", "3")], "the NO view, one level" + socket.send_json({"op": "unsubscribe", "market_id": P}) + assert receive_until(socket, "unsubscribed")["market_id"] == P + socket.send_json({"op": "unsubscribe", "market_id": P}) + assert receive_until(socket, "error")["code"] == "not_subscribed" + + @pytest.mark.parametrize("request_, code", [ + ({"op": "subscribe", "market_id": "KXBARE"}, "bad_market_id"), + ({"op": "subscribe", "market_id": P, "channels": ["orders"]}, "bad_request"), + ({"op": "subscribe", "market_id": "sx_bet:0xabc"}, "credentials_required"), + ({"op": "dance"}, "bad_request"), + ]) + def test_a_request_the_server_cannot_serve_gets_an_error_frame(self, serve_harness, request_, code): + with serve_harness() as harness: + with harness.client.websocket_connect(f"/ws/market?key={harness.token}") as socket: + socket.send_json(request_) + assert receive_until(socket, "error")["code"] == code + socket.send_json({"op": "subscribe", "market_id": P}) + assert receive_until(socket, "subscribed")["market_id"] == P, "the socket stays usable" + + def test_a_channel_the_venue_does_not_stream_is_reported_not_faked(self, serve_harness): + with serve_harness() as harness: + with harness.client.websocket_connect(f"/ws/market?key={harness.token}") as socket: + socket.send_json({"op": "subscribe", "market_id": "limitless:some-slug", "channels": ["book", "trades"]}) + subscribed = receive_until(socket, "subscribed") + assert subscribed["channels"] == ["book"] and subscribed["unsupported"] == ["trades"] + + +class TestSharing: + def test_two_subscribers_share_one_venue_connection(self, serve_harness): + with serve_harness() as harness: + with harness.client.websocket_connect(url(harness, P)) as first, \ + harness.client.websocket_connect(url(harness, P)) as second: + receive_until(first, "subscribed") + receive_until(second, "subscribed") + assert len(harness.opened["polymarket"]) == 1, "one connection" + stream = harness.stream("polymarket") + assert stream.watched == [("book", (P,))], "subscribed upstream once" + harness.push("polymarket", stream.snapshot(P, bids=[("0.40", "10")])) + assert receive_until(first, "book", ready=True)["best_bid"] == "0.40" + assert receive_until(second, "book", ready=True)["best_bid"] == "0.40" + + def test_the_last_subscriber_leaving_unwatches_and_closes_the_connection(self, serve_harness): + with serve_harness() as harness: + with harness.client.websocket_connect(url(harness, P, P2)) as first: + receive_until(first, "subscribed", market_id=P2) + with harness.client.websocket_connect(url(harness, P)) as second: + receive_until(second, "subscribed") + stream = harness.stream("polymarket") + harness.wait_until(lambda: len(harness.stack.market_streams.connections["polymarket"] + .sessions_by_market[P]) == 1) + assert stream.unwatched == [] and not stream.closed, "the first subscriber still holds both" + first.send_json({"op": "unsubscribe", "market_id": P2}) + receive_until(first, "unsubscribed") + assert stream.unwatched == [P2] and not stream.closed + harness.wait_until(hub_has_no_connection(harness, "polymarket")) + assert stream.unwatched == [P2, P] and stream.closed + + with harness.client.websocket_connect(url(harness, P)) as again: + receive_until(again, "subscribed") + assert len(harness.opened["polymarket"]) == 2, "a new subscriber opens a fresh connection" + + def test_the_engine_and_the_screen_share_the_engines_connection(self, serve_harness): + with serve_harness() as harness: + engine_stream = harness.stream("kalshi") + with harness.client.websocket_connect(url(harness, K)) as socket: + receive_until(socket, "subscribed") + assert "kalshi" not in harness.opened, "no second Kalshi connection" + harness.push("kalshi", engine_stream.snapshot(K, bids=[("0.30", "8")], asks=[("0.35", "2")])) + assert receive_until(socket, "book", ready=True)["best_ask"] == "0.35" + + # A stop on the same market: the engine watches the very book the screen reads. + placed = harness.client.post("/trading/orders", headers=harness.auth, json={ + "market_id": K, "side": "sell", "quantity": "1", "type": "stop_market", "stop_price": "0.10", + "time_in_force": "gtc", "book": "harness"}) + assert placed.status_code in (201, 202), placed.text + feeds = harness.stack.feeds + harness.wait_until(lambda: K in feeds.watching["kalshi"]) + assert harness.stack.engine.books[K] is engine_stream.books[K] + harness.wait_until(lambda: not harness.stack.market_streams.connections["kalshi"].sessions_by_market) + assert engine_stream.watched.count(("book", (K,))) == 2, "the engine's sync re-subscribes idempotently" + assert not engine_stream.closed, "the engine's connection outlives the screen" + assert harness.stack.engine.books[K] is engine_stream.books[K] + + def test_a_market_the_engine_watches_is_not_unwatched_when_the_screen_leaves(self, serve_harness): + with serve_harness() as harness: + hub = harness.stack.market_streams + harness.call(lambda: hub.feeds.watching.setdefault("polymarket", set()).add(P)) + with harness.client.websocket_connect(url(harness, P)) as socket: + receive_until(socket, "subscribed") + stream = harness.stream("polymarket") + harness.wait_until(hub_has_no_connection(harness, "polymarket")) + assert stream.unwatched == [], "the engine still holds it" + + +class TestReconnecting: + def test_after_a_drop_the_book_is_marked_not_ready_then_the_fresh_snapshot_is_sent(self, serve_harness): + with serve_harness() as harness: + with harness.client.websocket_connect(url(harness, P)) as socket: + receive_until(socket, "subscribed") + stream = harness.stream("polymarket") + harness.push("polymarket", stream.snapshot(P, bids=[("0.40", "10")])) + receive_until(socket, "book", ready=True) + + harness.push("polymarket", stream.drop()) + assert receive_until(socket, "status")["state"] == "disconnected" + stale = receive_until(socket, "book") + assert stale["ready"] is False and stale["bids"] == [], "a book the stream lost is not shown" + + harness.push("polymarket", stream.reconnected()) + assert receive_until(socket, "status")["state"] == "connected" + harness.push("polymarket", stream.snapshot(P, bids=[("0.44", "1")])) + fresh = receive_until(socket, "book", ready=True) + assert levels(fresh, "bids") == [("0.44", "1")] + + def test_a_stream_that_ends_tells_its_subscribers_and_the_next_subscribe_reopens(self, serve_harness): + with serve_harness() as harness: + with harness.client.websocket_connect(url(harness, P)) as socket: + receive_until(socket, "subscribed") + harness.call(harness.stream("polymarket").push, None) # refused for good: iteration ends + assert receive_until(socket, "error")["code"] == "stream_ended" + socket.send_json({"op": "subscribe", "market_id": P}) + receive_until(socket, "subscribed") + assert len(harness.opened["polymarket"]) == 2 + + +class TestVenueStatus: + def states(self, harness): + answer = harness.client.get("/market/streams", headers=harness.auth) + assert answer.status_code == 200, answer.text + return {row["venue"]: row["state"] for row in answer.json()} + + def test_each_open_connection_reports_its_own_state(self, serve_harness): + with serve_harness() as harness: + assert harness.client.get("/market/streams").status_code == 401, "the token is required" + assert "polymarket" not in self.states(harness), "nothing streams Polymarket yet" + with harness.client.websocket_connect(url(harness, P)) as socket: + receive_until(socket, "subscribed") + stream = harness.stream("polymarket") + harness.push("polymarket", stream.reconnected()) + receive_until(socket, "status") + assert self.states(harness)["polymarket"] == "connected" + harness.push("polymarket", stream.drop()) + receive_until(socket, "status") + assert self.states(harness)["polymarket"] == "reconnecting" + assert "kalshi" in self.states(harness), "the engine's own Kalshi connection is always listed" + + +def test_streams_turned_off_answer_with_an_error_not_a_poll(serve_harness, tmp_path): + with serve_harness(config={"streams": False}) as harness: + with harness.client.websocket_connect(url(harness, P)) as socket: + assert receive_until(socket, "error")["code"] == "streams_disabled" + assert "polymarket" not in harness.opened diff --git a/tests/test_serve_orders.py b/tests/test_serve_orders.py new file mode 100644 index 0000000..edcb146 --- /dev/null +++ b/tests/test_serve_orders.py @@ -0,0 +1,348 @@ +"""`/trading/orders` through a whole `synpath serve`, on the mocked Kalshi. + +What a client of the server (the terminal, `synpath.Client(server=...)`) +relies on: an order whose outcome is not known yet is a 202 with its own +error code and the `client_order_id` to retry under, and the retry never +sends twice; a cancel never relabels a fill; and the order list shows every +state, engine-held parents included, newest first. +""" +from __future__ import annotations + +from decimal import Decimal as D + +import pytest + +from serve_harness import MockKalshi, receive_until, serve_harness # noqa: F401 (the fixture) +from synpath.trading.errors import CancelUnconfirmed, SubmissionInDoubt, SubmissionPending +from synpath.trading.types import OrderRequest, OrderStatus + +K = "kalshi:KXHARNESS-1" + + +def limit(**changes) -> dict: + return {"market_id": K, "side": "buy", "quantity": "2", "type": "limit", "price": "0.40", + "time_in_force": "gtc", **changes} + + +def place(harness, body: dict): + return harness.client.post("/trading/orders", headers=harness.auth, json=body) + + +class TestInDoubt: + def test_a_lost_response_is_202_submission_in_doubt_and_the_retry_adopts_without_resending(self, serve_harness): + kalshi = MockKalshi(lose_create_response=True) + with serve_harness(kalshi=kalshi) as harness: + first = place(harness, limit(client_order_id="ticket-1")) + assert first.status_code == 202, first.text + error = first.json()["error"] + assert error["code"] == "submission_in_doubt" + assert error["details"]["retryable"] is True + assert error["details"]["client_order_id"] == "ticket-1" + assert error["details"]["venue"] == "kalshi" + + kalshi.lose_create_response = False + again = place(harness, limit(client_order_id="ticket-1")) + assert again.status_code == 201, again.text + assert again.json()["client_order_id"] == "ticket-1" and again.json()["status"] == "open" + assert len(kalshi.creates) == 1, "the retry checked the venue and never sent again" + + def test_a_retry_while_the_venue_has_no_record_yet_is_202_submission_pending(self, serve_harness): + kalshi = MockKalshi(lose_create_response=True) + with serve_harness(kalshi=kalshi) as harness: + assert place(harness, limit(client_order_id="ticket-2")).status_code == 202 + kalshi.orders.clear() # the venue never took it, as far as it will say yet + + again = place(harness, limit(client_order_id="ticket-2")) + assert again.status_code == 202, again.text + error = again.json()["error"] + assert error["code"] == "submission_pending" + assert error["details"]["retryable"] is True and error["details"]["client_order_id"] == "ticket-2" + assert len(kalshi.creates) == 1 + + def test_an_order_sent_without_an_id_is_in_doubt_under_the_id_the_engine_gave_it(self, serve_harness): + kalshi = MockKalshi(lose_create_response=True) + with serve_harness(kalshi=kalshi) as harness: + first = place(harness, limit()) + assigned = first.json()["error"]["details"]["client_order_id"] + assert first.status_code == 202 and assigned == kalshi.creates[0]["client_order_id"] + + kalshi.lose_create_response = False + again = place(harness, limit(client_order_id=assigned)) + assert again.status_code == 201 and again.json()["id"] == "kx-1" + assert len(kalshi.creates) == 1 + + def test_a_different_instruction_under_the_same_id_is_refused(self, serve_harness): + kalshi = MockKalshi(lose_create_response=True) + with serve_harness(kalshi=kalshi) as harness: + assert place(harness, limit(client_order_id="ticket-3")).status_code == 202 + other = place(harness, limit(client_order_id="ticket-3", quantity="3")) + assert other.status_code == 400 and len(kalshi.creates) == 1 + + def test_the_python_client_raises_the_in_doubt_types_with_the_id(self, serve_harness): + kalshi = MockKalshi(lose_create_response=True) + with serve_harness(kalshi=kalshi) as harness: + client = harness.remote_client() + request = OrderRequest(market_id=K, side="buy", quantity="2", price="0.40", client_order_id="ticket-4") + try: + with pytest.raises(SubmissionInDoubt) as in_doubt: + harness.call(client.create_order, request) + assert in_doubt.value.client_order_id == "ticket-4" + + saved = dict(kalshi.orders) + kalshi.orders.clear() + with pytest.raises(SubmissionPending) as pending: + harness.call(client.create_order, request) + assert pending.value.client_order_id == "ticket-4" + + kalshi.orders.update(saved) + kalshi.lose_create_response = False + order = harness.call(client.create_order, request) + assert order.client_order_id == "ticket-4" and len(kalshi.creates) == 1 + finally: + harness.call(client.close) + + +class TestCancel: + def test_cancelling_an_order_the_venue_filled_returns_it_closed(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + placed = place(harness, limit(client_order_id="cancel-1")).json() + kalshi.fill(placed["id"]) + + cancelled = harness.client.delete(f"/trading/orders/{placed['id']}", headers=harness.auth) + assert cancelled.status_code == 200, cancelled.text + assert cancelled.json()["status"] == "closed" and D(cancelled.json()["filled"]) == 2 + again = harness.client.delete(f"/trading/orders/{placed['id']}", headers=harness.auth) + assert again.json()["status"] == "closed" and kalshi.cancels == [placed["id"]] + + def test_a_cancel_the_venue_cannot_place_is_409_cancel_unconfirmed(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + placed = place(harness, limit(client_order_id="cancel-2")).json() + kalshi.forgotten.add(placed["id"]) + + refused = harness.client.delete(f"/trading/orders/{placed['id']}", headers=harness.auth) + assert refused.status_code == 409 + assert refused.json()["error"]["code"] == "cancel_unconfirmed" + assert refused.json()["error"]["details"]["retryable"] is False + shown = harness.client.get(f"/trading/orders/{placed['id']}", headers=harness.auth).json() + assert shown["status"] == "open" + + client = harness.remote_client() + try: + with pytest.raises(CancelUnconfirmed): + harness.call(client.cancel_order, placed["id"]) + finally: + harness.call(client.close) + + +def orders(harness, **params): + response = harness.client.get("/trading/orders", headers=harness.auth, params=params) + assert response.status_code == 200, response.text + return response.json() + + +def every_page(harness, **params): + rows, cursor, pages = [], None, 0 + while True: + page = orders(harness, **params, **({"cursor": cursor} if cursor else {})) + rows += page["data"] + pages += 1 + cursor = page["next_cursor"] + if not cursor: + return rows, pages + + +class TestListing: + """One of each state a Kalshi limit, stop-market or stop-limit can be in, + placed a few milliseconds apart so "newest first" is observable.""" + + def place_one_of_everything(self, harness, kalshi) -> dict[str, dict]: + import time + + def step(): + time.sleep(0.005) + + placed: dict[str, dict] = {} + placed["resting"] = place(harness, limit(client_order_id="list-resting")).json() + step() + placed["filled"] = place(harness, limit(client_order_id="list-filled", time_in_force="ioc")).json() + step() + cancelled = place(harness, limit(client_order_id="list-cancelled")).json() + harness.client.delete(f"/trading/orders/{cancelled['id']}", headers=harness.auth) + placed["cancelled"] = cancelled + step() + placed["waiting"] = place(harness, {"market_id": K, "side": "sell", "quantity": "1", "type": "stop_market", + "stop_price": "0.10", "client_order_id": "list-waiting"}).json() + step() + placed["triggered"] = place(harness, {"market_id": K, "side": "buy", "quantity": "1", "type": "stop_limit", + "stop_price": "0.45", "price": "0.46", "time_in_force": "gtc", + "client_order_id": "list-triggered"}).json() + stream = harness.stream("kalshi") + harness.wait_until(lambda: K in harness.stack.feeds.watching["kalshi"]) + harness.push("kalshi", stream.snapshot(K, bids=[("0.44", "5")], asks=[("0.46", "5")])) + engine = harness.stack.engine + harness.wait_until(lambda: bool(engine.managed_order_controller.get(placed["triggered"]["id"]).children)) + step() + placed["elsewhere"] = place(harness, limit(market_id="kalshi:KXHARNESS-2", + client_order_id="list-elsewhere")).json() + return placed + + def test_open_stays_the_default_and_all_shows_every_state_newest_first(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + placed = self.place_one_of_everything(harness, kalshi) + parent_id = placed["triggered"]["id"] + child_id = harness.stack.engine.managed_order_controller.get(parent_id).children[0].order_id + + default = {row["id"]: row["status"] for row in orders(harness)["data"]} + assert default == {placed["resting"]["id"]: "open", placed["waiting"]["id"]: "waiting", + parent_id: "triggered", child_id: "open", placed["elsewhere"]["id"]: "open"} + + everything = orders(harness, scope="all") + assert everything["next_cursor"] is None and everything["count"] == 7 + statuses = [(row["client_order_id"], row["status"], row["held_by"]) for row in everything["data"]] + child = next(row for row in everything["data"] if row["id"] == child_id) + assert child["parent_id"] == parent_id and child["held_by"] == "venue" + assert statuses[0] == ("list-elsewhere", "open", "venue"), "newest first" + assert ("list-triggered", "triggered", "engine") in statuses + assert statuses[-3:] == [("list-cancelled", "canceled", "venue"), ("list-filled", "closed", "venue"), + ("list-resting", "open", "venue")] + assert ("list-waiting", "waiting", "engine") in statuses + + def test_all_pages_by_cursor_and_filters_by_market_and_time(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + placed = self.place_one_of_everything(harness, kalshi) + everything = [row["id"] for row in orders(harness, scope="all")["data"]] + + paged, pages = every_page(harness, scope="all", limit=2) + assert [row["id"] for row in paged] == everything and pages == 4 + + on_market = orders(harness, scope="all", market_id=K)["data"] + assert len(on_market) == 6 and all(row["market_id"] == K for row in on_market) + assert [row["id"] for row in orders(harness, market_id="kalshi:KXHARNESS-2")["data"]] == \ + [placed["elsewhere"]["id"]] + + since_ms = harness.call(harness.stack.engine.journal.order, "kalshi", placed["waiting"]["id"]).created_at + recent = {row["client_order_id"] for row in orders(harness, scope="all", since=since_ms)["data"]} + assert recent >= {"list-waiting", "list-triggered", "list-elsewhere"} + assert not recent & {"list-resting", "list-filled", "list-cancelled"} + + def test_a_finished_parent_is_listed_after_a_restart(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + parent = place(harness, {"market_id": K, "side": "sell", "quantity": "1", "type": "stop_market", + "stop_price": "0.10", "client_order_id": "list-ended"}).json() + harness.client.delete(f"/trading/orders/{parent['id']}", headers=harness.auth) + engine = harness.stack.engine + harness.call(lambda: engine.managed_order_controller.parents.pop(parent["id"])) # what a restart leaves + rows = orders(harness, scope="all")["data"] + assert [(row["id"], row["status"], row["held_by"]) for row in rows] == \ + [(parent["id"], "canceled", "engine")] + + def test_bad_listing_requests_are_refused(self, serve_harness): + with serve_harness() as harness: + for params in ({"scope": "everything"}, {"limit": 0}, {"limit": 1001}): + response = harness.client.get("/trading/orders", headers=harness.auth, params=params) + assert response.status_code == 422, (params, response.text) + response = harness.client.get("/trading/orders", headers=harness.auth, + params={"scope": "all", "cursor": "not-a-cursor"}) + assert response.status_code == 400 and response.json()["error"]["code"] == "bad_request" + assert harness.client.get("/trading/orders", params={"scope": "all"}).status_code == 401 + + def test_the_python_client_lists_every_state_through_the_server(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + self.place_one_of_everything(harness, kalshi) + client = harness.remote_client() + try: + first = harness.call(lambda: client.fetch_orders(limit=3)) + assert len(first) == 3 and first.next_cursor + rest = harness.call(lambda: client.fetch_orders(limit=10, cursor=first.next_cursor)) + assert len(rest) == 4 and rest.next_cursor is None + assert {o.status for o in [*first, *rest]} >= {OrderStatus.CLOSED, OrderStatus.CANCELED, + OrderStatus.WAITING, OrderStatus.TRIGGERED} + open_only = harness.call(lambda: client.fetch_orders(scope="open")) + assert {o.status for o in open_only} <= {OrderStatus.OPEN, OrderStatus.WAITING, + OrderStatus.TRIGGERED} + finally: + harness.call(client.close) + + +class TestStopsReadTheBookTheyAreHanded: + """A stop reads the book the engine already holds the moment it starts + watching, not only the next change. Kalshi sends no fresh snapshot for a + market that is already subscribed on the connection (the screen's + `/ws/market`, say), so a quiet market whose ask already meets the stop + would otherwise leave it waiting forever.""" + + @staticmethod + def stop(**changes) -> dict: + return {"market_id": K, "side": "buy", "quantity": "1", "type": "stop_limit", "stop_price": "0.94", + "price": "0.95", "time_in_force": "gtc", **changes} + + @staticmethod + def screen_loaded(harness, socket, *, bid: str = "0.92", ask: str = "0.94") -> None: + receive_until(socket, "subscribed") + harness.push("kalshi", harness.stream("kalshi").snapshot(K, bids=[(bid, "5")], asks=[(ask, "5")])) + receive_until(socket, "book", ready=True) + + @staticmethod + def state(harness, parent_id: str) -> str: + return harness.stack.engine.managed_order_controller.get(parent_id).state + + def test_a_stop_already_met_on_a_book_the_screen_loaded_triggers_without_another_update(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + with harness.client.websocket_connect(f"/ws/market?key={harness.token}&market_id={K}") as socket: + self.screen_loaded(harness, socket) + placed = place(harness, self.stop(client_order_id="loaded-1")) + assert placed.status_code == 201, placed.text + harness.wait_until(lambda: len(kalshi.creates) == 1) + assert kalshi.creates[0]["price"] == "0.9500" + assert self.state(harness, placed.json()["id"]) == "working" + + def test_a_stop_met_by_the_first_snapshot_of_a_market_nobody_watched_triggers(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + placed = place(harness, self.stop(client_order_id="fresh-1")).json() + harness.wait_until(lambda: K in harness.stack.feeds.watching["kalshi"]) + assert self.state(harness, placed["id"]) == "waiting" and not kalshi.creates + harness.push("kalshi", harness.stream("kalshi").snapshot(K, bids=[("0.92", "5")], asks=[("0.94", "5")])) + harness.wait_until(lambda: len(kalshi.creates) == 1) + + def test_a_stop_not_met_on_a_loaded_book_keeps_waiting(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + with harness.client.websocket_connect(f"/ws/market?key={harness.token}&market_id={K}") as socket: + self.screen_loaded(harness, socket, ask="0.93") + placed = place(harness, self.stop(client_order_id="loaded-2")).json() + harness.wait_until(lambda: K in harness.stack.feeds.watching["kalshi"]) + harness.call(harness.stack.feeds.sync) + assert self.state(harness, placed["id"]) == "waiting" and not kalshi.creates + + def test_a_mid_stop_reads_the_loaded_book_and_a_last_stop_waits_for_a_new_print(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + with harness.client.websocket_connect(f"/ws/market?key={harness.token}&market_id={K}") as socket: + self.screen_loaded(harness, socket, bid="0.92", ask="0.96") + last = place(harness, self.stop(stop_price="0.90", trigger_source="last", + client_order_id="loaded-last")).json() + mid = place(harness, self.stop(trigger_source="mid", client_order_id="loaded-mid")).json() + harness.wait_until(lambda: len(kalshi.creates) == 1) + assert self.state(harness, mid["id"]) == "working" + assert self.state(harness, last["id"]) == "waiting", "a book is not a trade" + + def test_a_new_stop_on_a_market_the_engine_already_watches_reads_its_book_at_once(self, serve_harness): + kalshi = MockKalshi() + with serve_harness(kalshi=kalshi) as harness: + place(harness, self.stop(side="sell", type="stop_market", stop_price="0.10", price=None, + client_order_id="watching")) + harness.wait_until(lambda: K in harness.stack.feeds.watching["kalshi"]) + harness.push("kalshi", harness.stream("kalshi").snapshot(K, bids=[("0.92", "5")], asks=[("0.94", "5")])) + harness.wait_until(lambda: harness.stack.engine.books[K].best_ask == D("0.94")) + + placed = place(harness, self.stop(client_order_id="second")) + assert placed.status_code == 201, placed.text + assert placed.json()["status"] == "triggered" and len(kalshi.creates) == 1 diff --git a/tests/test_server.py b/tests/test_server.py index 86066cd..4633269 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -83,8 +83,8 @@ def fetch_trades(self, market_id, *, since=None, limit=None, cursor=None): next_cursor="100", ) + # Five hourly bars the stub's market has. BARS = [1_700_000_000_000 + n * 3_600_000 for n in range(5)] - """Five hourly bars the stub's market has.""" def fetch_ohlcv(self, market_id, *, timeframe="1h", since=None, until=None, limit=None): self._check("fetch_ohlcv", market_id=market_id, timeframe=timeframe, since=since, until=until, limit=limit) diff --git a/tests/test_server_rfq.py b/tests/test_server_rfq.py new file mode 100644 index 0000000..2c49c68 --- /dev/null +++ b/tests/test_server_rfq.py @@ -0,0 +1,541 @@ +"""The RFQ and combo routes under /trading, offline (P5 of docs/rfq-combo.md). + +A real engine on `PaperRfqVenue` (the paper RFQ venue of P3/P4) beside a plain +paper venue without RFQ, a real control store, and FastAPI's test client. +Covered: every route; each grant refusing; 202 by default and 200 only when a +waited-for quote is final; in-doubt sends answering 202 with the intent key; +risk refusals carrying `rule`; the event socket carrying `rfq.*`, `quote.*` +and `combo.saved`; `POST /halt` cancelling our RFQs and quotes under the +`cancel` policy; and the OpenAPI document. +""" +from __future__ import annotations + +import json +from decimal import Decimal as D +from pathlib import Path + +import pytest + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.engine.paper import PaperRfqVenue, PaperVenue +from synpath.errors import NetworkError +from synpath.server.store import ControlStore +from synpath.server.trading import create_trading_app +from synpath.trading.types import Account, QuoteStatus, RfqStatus + +pytestmark = pytest.mark.anyio + +VENUE = "polymarket_us" +ACCOUNT = Account(venue=VENUE, name="desk-a") +KALSHI = Account(venue="kalshi", name="desk-a") +MARKET = "polymarket_us:tec-nba-a" +LEG_B = "polymarket_us:tec-nba-b" + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture +async def server(tmp_path: Path): + """An engine with an RFQ venue and a plain one, and keys of different powers.""" + from fastapi.testclient import TestClient + + rfq = PaperRfqVenue(venue=VENUE, account=ACCOUNT, cash=D("10000")) + plain = PaperVenue(venue="kalshi", account=KALSHI, cash=D("10000")) + engine = Engine( + {VENUE: rfq, "kalshi": plain}, + EngineConfig(journal_path=str(tmp_path / "engine.db"), require_lease=True, in_doubt_timeout_s=0, + rfq_poll_interval_s=0.01, rfq_incoming_interval_s=None), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None, max_order_contracts=D("500")), + accounts={VENUE: ACCOUNT, "kalshi": KALSHI}, + ) + await engine.start() + store = await ControlStore(str(tmp_path / "control.db")).open() + owner, owner_key = await store.bootstrap("owner") + await store.acting_as(f"user:{owner.id}", "setup") + + viewer = await store.create_user("viewer") + await store.grant(viewer.id, ACCOUNT.key, "view") + viewer_key = await store.issue_key(viewer.id) + + trader = await store.create_user("trader") + await store.grant(trader.id, ACCOUNT.key, "trade") + await store.grant(trader.id, ACCOUNT.key, "view") + trader_key = await store.issue_key(trader.id) + + other = await store.create_user("other") # trades kalshi only + await store.grant(other.id, KALSHI.key, "trade") + await store.grant(other.id, KALSHI.key, "view") + other_key = await store.issue_key(other.id) + + with TestClient(create_trading_app(engine, store)) as client: + yield {"client": client, "engine": engine, "paper": rfq, "store": store, + "owner": owner_key.secret, "viewer": viewer_key.secret, "trader": trader_key.secret, + "other": other_key.secret} + await store.close() + await engine.stop() + + +def auth(key: str) -> dict[str, str]: + return {"Authorization": f"Bearer {key}"} + + +def rfq_body(**kw) -> dict: + return {"market_id": MARKET, "quantity": "10", "book": "alpha", "trader": "yuki", **kw} + + +def accept_body(quote: dict, **kw) -> dict: + return {"rfq_id": quote["rfq_id"], "outcome": "yes", "action": "buy", "worst_price": "0.50", "book": "alpha", **kw} + + +def maker_body(rfq_id: str, **kw) -> dict: + return {"rfq_id": rfq_id, "bid": "0.40", "ask": "0.50", "book": "mm", **kw} + + +def legs() -> list[dict]: + return [{"market_id": MARKET, "outcome": "yes"}, {"market_id": LEG_B, "outcome": "no"}] + + +def new_rfq(server, **kw) -> tuple[dict, dict]: + """An RFQ of ours and the paper maker's quote on it.""" + client = server["client"] + created = client.post("/rfqs", json=rfq_body(**kw), headers=auth(server["trader"])) + assert created.status_code == 201, created.text + rfq = created.json() + quotes = client.get(f"/rfqs/{rfq['id']}/quotes", headers=auth(server["viewer"])).json()["data"] + return rfq, quotes[0] + + +def incoming(server, **kw): + return server["paper"].add_incoming_rfq(MARKET, quantity=D(kw.pop("quantity", "10")), **kw) + + +# --------------------------------------------------------------------------- +# Taker routes +# --------------------------------------------------------------------------- + +class TestRfqs: + async def test_create_list_get_quotes_and_cancel(self, server): + client = server["client"] + rfq, quote = new_rfq(server) + assert rfq["venue"] == VENUE and rfq["market_id"] == MARKET and rfq["quantity"] == "10" + assert rfq["book"] == "alpha" and rfq["trader"] == "yuki" and rfq["status"] == "open" + assert quote["ask"] == "0.45" and quote["bid"] == "0.40" and quote["status"] == "open" + + listed = client.get("/rfqs", headers=auth(server["viewer"])).json() + assert listed["count"] == 1 and listed["data"][0]["id"] == rfq["id"] and listed["data"][0]["book"] == "alpha" + held = client.get("/rfqs?source=engine", headers=auth(server["viewer"])).json() + assert [r["id"] for r in held["data"]] == [rfq["id"]] + assert client.get("/rfqs?status=closed", headers=auth(server["viewer"])).json()["count"] == 0 + assert client.get(f"/rfqs?venue={VENUE}&market_id={MARKET}", headers=auth(server["viewer"])).json()["count"] == 1 + assert client.get("/rfqs?market_id=polymarket_us:other", headers=auth(server["viewer"])).json()["count"] == 0 + + one = client.get(f"/rfqs/{rfq['id']}", headers=auth(server["viewer"])) + assert one.status_code == 200 and one.json()["id"] == rfq["id"] and one.json()["book"] == "alpha" + + canceled = client.delete(f"/rfqs/{rfq['id']}", headers=auth(server["trader"])) + assert canceled.status_code == 200 + assert canceled.json() == {"id": rfq["id"], "venue": VENUE, "kind": "rfq", "status": "canceled"} + assert client.get(f"/rfqs/{rfq['id']}", headers=auth(server["viewer"])).json()["status"] == "closed" + assert client.get("/rfqs?source=engine", headers=auth(server["viewer"])).json()["count"] == 0 + quotes = client.get(f"/rfqs/{rfq['id']}/quotes", headers=auth(server["viewer"])).json()["data"] + assert quotes[0]["status"] == "canceled" + assert client.get(f"/rfqs/{rfq['id']}/quotes?status=open", headers=auth(server["viewer"])).json()["count"] == 0 + + async def test_by_amount_on_a_combo(self, server): + client = server["client"] + made = client.post("/combos", json={"legs": legs()}, headers=auth(server["trader"])) + assert made.status_code == 200, made.text + combo = made.json() + again = client.post("/combos", json={"legs": legs()}, headers=auth(server["trader"])).json() + assert again["market_id"] == combo["market_id"], "the same legs give the same combo" + got = client.get(f"/combos/{combo['market_id']}", headers=auth(server["viewer"])) + assert got.status_code == 200 and [l["market_id"] for l in got.json()["legs"]] == [MARKET, LEG_B] + + created = client.post("/rfqs", json={"market_id": combo["market_id"], "amount": "20"}, + headers=auth(server["trader"])) + assert created.status_code == 201, created.text + rfq = created.json() + assert rfq["combo_id"] == combo["market_id"] and rfq["market_id"] is None and rfq["amount"] == "20" + assert rfq["combo"]["legs"][1]["outcome"] == "no" + + async def test_bodies_and_ids_refused(self, server): + client = server["client"] + both = client.post("/rfqs", json=rfq_body(amount="5"), headers=auth(server["trader"])) + assert both.status_code == 422 and both.json()["error"]["code"] == "validation_error" + assert client.get("/rfqs/nope", headers=auth(server["viewer"])).status_code == 404 + unknown = client.get("/rfqs/x?venue=nowhere", headers=auth(server["owner"])) + assert unknown.status_code == 404 and unknown.json()["error"]["code"] == "unknown_venue" + # A venue without RFQs refuses before anything is sent. + no_rfq = client.post("/rfqs", json=rfq_body(market_id="kalshi:KX-A"), headers=auth(server["owner"])) + assert no_rfq.status_code == 400 and "rfq_taker" in no_rfq.json()["error"]["message"] + assert client.get(f"/combos/{VENUE}:combo-nope", headers=auth(server["viewer"])).status_code == 404 + + async def test_a_venue_without_a_call_is_501(self, server): + lookup = server["client"].post("/combos/lookup", json={"legs": legs()}, headers=auth(server["viewer"])) + assert lookup.status_code == 501 and lookup.json()["error"]["code"] == "not_supported" + + +class TestComboCollectionsAndMarkets: + async def test_collection_id_reaches_the_venue(self, server): + client, paper = server["client"], server["paper"] + made = client.post("/combos", json={"legs": legs(), "collection_id": "KXMVECROSSCATEGORY-SHARD1-R"}, + headers=auth(server["trader"])) + assert made.status_code == 200, made.text + assert paper.combo_collections[-1] == "KXMVECROSSCATEGORY-SHARD1-R" + assert made.json()["collection_id"] == "KXMVECROSSCATEGORY-SHARD1-R" + client.post("/combos", json={"legs": legs()}, headers=auth(server["trader"])) + assert paper.combo_collections[-1] is None, "left out, nothing is named" + + async def test_several_collections_are_a_400_naming_them(self, server, monkeypatch): + from synpath.trading.errors import InvalidOrder + + async def ambiguous(legs, *, collection_id=None): + exc = InvalidOrder("kalshi: 2 open collections take these legs (A-R, B-R); name one with collection_id") + exc.collections = ["A-R", "B-R"] + raise exc + + monkeypatch.setattr(server["paper"], "create_combo", ambiguous) + refused = server["client"].post("/combos", json={"legs": legs()}, headers=auth(server["trader"])) + assert refused.status_code == 400 + error = refused.json()["error"] + assert "collection_id" in error["message"] and error["details"]["collections"] == ["A-R", "B-R"] + + async def test_eligible_markets_are_served_and_cached(self, server, monkeypatch): + from synpath.trading.types import ComboMarket + from synpath.types import Page + + seen: list[dict] = [] + + async def eligible(*, event_id=None, tag=None, limit=None, cursor=None): + seen.append({"event_id": event_id, "tag": tag, "limit": limit, "cursor": cursor}) + row = ComboMarket(market_id=MARKET, venue=VENUE, title="A wins", event_id=f"{VENUE}:tec-nba", + max_legs=50, tags=["nba"]) + return Page([row], next_cursor="50") + + monkeypatch.setattr(server["paper"], "fetch_combo_eligible_markets", eligible, raising=False) + client = server["client"] + url = f"/combos/markets?venue={VENUE}&tag=nba&limit=50" + first = client.get(url, headers=auth(server["viewer"])) + assert first.status_code == 200, first.text + page = first.json() + assert page["count"] == 1 and page["next_cursor"] == "50" + assert page["data"][0] | {"info": {}} == { + "market_id": MARKET, "venue": VENUE, "title": "A wins", "event_id": f"{VENUE}:tec-nba", + "event_title": None, "position_ids": None, "min_legs": 2, "max_legs": 50, "status": None, + "tags": ["nba"], "info": {}} + assert seen == [{"event_id": None, "tag": "nba", "limit": 50, "cursor": None}] + assert client.get(url, headers=auth(server["viewer"])).json() == page + assert len(seen) == 1, "a repeat inside the TTL is served from memory" + client.get(url + "&cursor=50", headers=auth(server["viewer"])) + assert len(seen) == 2 and seen[-1]["cursor"] == "50", "another page is another read" + + async def test_eligible_markets_grants_and_501(self, server): + client = server["client"] + denied = client.get(f"/combos/markets?venue={VENUE}", headers=auth(server["other"])) + assert denied.status_code == 403 + none = client.get(f"/combos/markets?venue={VENUE}", headers=auth(server["viewer"])) + assert none.status_code == 501 and none.json()["error"]["code"] == "not_supported" + + async def test_collections_are_cached(self, server, monkeypatch): + from synpath.types import Page + + calls = [] + + async def collections(**kw): + calls.append(kw) + return Page([{"collection_ticker": "A-R", "associated_event_tickers": ["E1", "E2"]}], next_cursor=None) + + monkeypatch.setattr(server["paper"], "fetch_combo_collections", collections, raising=False) + client = server["client"] + for _ in range(2): + got = client.get(f"/combos/collections?venue={VENUE}", headers=auth(server["viewer"])) + assert got.status_code == 200 and got.json()["data"][0]["associated_event_tickers"] == ["E1", "E2"] + assert len(calls) == 1 + + +class TestAccept: + async def test_accept_answers_202_at_once(self, server): + client, engine = server["client"], server["engine"] + _, quote = new_rfq(server) + response = client.post(f"/quotes/{quote['id']}/accept", json=accept_body(quote), headers=auth(server["trader"])) + assert response.status_code == 202, response.text + body = response.json() + assert body["quote"]["status"] == "accepted" and body["final"] is False + assert body["client_order_id"] == f"rfq-accept:{VENUE}:{quote['id']}" and body["intent_state"] == "sending" + assert body["venue"] == VENUE and body["order_id"] is None + # A retry never sends twice. + again = client.post(f"/quotes/{quote['id']}/accept", json=accept_body(quote), headers=auth(server["trader"])) + assert again.status_code == 202 and server["paper"].calls.count("accept_quote") == 1 + assert engine.rfq.following + + async def test_wait_answers_200_when_the_quote_is_final(self, server): + client, engine, paper = server["client"], server["engine"], server["paper"] + _, quote = new_rfq(server) + paper.auto_advance = True # the venue moves the quote each time it is read + response = client.post(f"/quotes/{quote['id']}/accept", json=accept_body(quote, wait=True, timeout_s=5), + headers=auth(server["trader"])) + assert response.status_code == 200, response.text + body = response.json() + assert body["final"] is True and body["quote"]["status"] == "executed" + assert body["intent_state"] == "sent" and body["order_id"] + positions = client.get("/positions", headers=auth(server["viewer"])).json()["data"] + assert [(p["market_id"], D(p["contracts"])) for p in positions] == [(MARKET, D("10"))] + + async def test_wait_that_times_out_is_still_202(self, server): + _, quote = new_rfq(server) + response = server["client"].post(f"/quotes/{quote['id']}/accept", + json=accept_body(quote, wait=True, timeout_s=0.05), + headers=auth(server["trader"])) + assert response.status_code == 202 and response.json()["final"] is False + + async def test_worst_price_refusal_carries_the_rule(self, server): + client, paper = server["client"], server["paper"] + _, quote = new_rfq(server) + response = client.post(f"/quotes/{quote['id']}/accept", json=accept_body(quote, worst_price="0.44"), + headers=auth(server["trader"])) + assert response.status_code == 409 + error = response.json()["error"] + assert error["code"] == "risk_rejected" and error["details"]["rule"] == "worst_price" + assert "accept_quote" not in paper.calls, "nothing was sent" + + async def test_size_limit_refusal_on_create(self, server): + response = server["client"].post("/rfqs", json=rfq_body(quantity="1000"), headers=auth(server["trader"])) + assert response.status_code == 409 + assert response.json()["error"]["details"]["rule"] == "max_order_contracts" + assert "create_rfq" not in server["paper"].calls + + async def test_no_answer_is_202_with_the_intent_key(self, server, monkeypatch): + client, paper = server["client"], server["paper"] + _, quote = new_rfq(server) + + async def lost(accept): + raise NetworkError("connection reset") + monkeypatch.setattr(paper, "accept_quote", lost) + response = client.post(f"/quotes/{quote['id']}/accept", json=accept_body(quote), headers=auth(server["trader"])) + assert response.status_code == 202 + error = response.json()["error"] + assert error["code"] == "submission_in_doubt" and error["details"]["retryable"] is True + assert error["details"]["client_order_id"] == f"rfq-accept:{VENUE}:{quote['id']}" + + async def test_a_halt_refuses_the_accept(self, server): + client = server["client"] + _, quote = new_rfq(server) + assert client.post("/halt", json={"reason": "test", "policy": "hold"}, headers=auth(server["trader"])).status_code == 200 + response = client.post(f"/quotes/{quote['id']}/accept", json=accept_body(quote), headers=auth(server["trader"])) + assert response.status_code == 409 and response.json()["error"]["details"]["rule"] == "kill_switch" + + +# --------------------------------------------------------------------------- +# Quoter routes +# --------------------------------------------------------------------------- + +class TestQuoter: + async def test_incoming_send_list_replace_cancel(self, server): + client = server["client"] + theirs = incoming(server) + rows = client.get("/rfqs/incoming", headers=auth(server["viewer"])).json() + assert [r["id"] for r in rows["data"]] == [theirs.id] and rows["data"][0]["mine"] is False + assert client.get("/rfqs/incoming?source=engine", headers=auth(server["viewer"])).status_code == 200 + + sent = client.post("/quotes", json=maker_body(theirs.id, expires_at=4_102_444_800_000), + headers=auth(server["trader"])) + assert sent.status_code == 201, sent.text + mine = sent.json() + assert mine["mine"] is True and mine["bid"] == "0.40" and mine["ask"] == "0.50" + + listed = client.get("/quotes", headers=auth(server["viewer"])).json() + assert [q["id"] for q in listed["data"]] == [mine["id"]] + assert listed["data"][0]["expires_at"] == 4_102_444_800_000 + assert client.get(f"/quotes?rfq_id={theirs.id}&status=open", headers=auth(server["viewer"])).json()["count"] == 1 + + replaced = client.put(f"/quotes/{mine['id']}", json=maker_body(theirs.id, bid="0.41", ask="0.49"), + headers=auth(server["trader"])) + assert replaced.status_code == 200, replaced.text + new = replaced.json() + assert new["id"] != mine["id"] and new["bid"] == "0.41" + + gone = client.delete(f"/quotes/{new['id']}", headers=auth(server["trader"])) + assert gone.status_code == 200 and gone.json()["kind"] == "quote" + assert client.get("/quotes?status=open", headers=auth(server["viewer"])).json()["count"] == 0 + + async def test_confirm_202_then_200_with_wait(self, server): + client, paper = server["client"], server["paper"] + theirs = incoming(server) + mine = client.post("/quotes", json=maker_body(theirs.id), headers=auth(server["trader"])).json() + paper.requester_accepts(mine["id"]) + confirmed = client.post(f"/quotes/{mine['id']}/confirm", json={}, headers=auth(server["trader"])) + assert confirmed.status_code == 202, confirmed.text + body = confirmed.json() + assert body["quote"]["status"] == "confirmed" and body["client_order_id"] == f"rfq-confirm:{VENUE}:{mine['id']}" + + other = client.post("/quotes", json=maker_body(incoming(server).id), headers=auth(server["trader"])).json() + paper.requester_accepts(other["id"]) + paper.auto_advance = True + waited = client.post(f"/quotes/{other['id']}/confirm", json={"wait": True, "timeout_s": 5}, + headers=auth(server["trader"])) + assert waited.status_code == 200, waited.text + assert waited.json()["quote"]["status"] == "executed" and waited.json()["intent_state"] == "sent" + + async def test_confirm_needs_no_body(self, server): + client, paper = server["client"], server["paper"] + mine = client.post("/quotes", json=maker_body(incoming(server).id), headers=auth(server["trader"])).json() + paper.requester_accepts(mine["id"]) + assert client.post(f"/quotes/{mine['id']}/confirm", headers=auth(server["trader"])).status_code == 202 + + async def test_send_refused_over_the_size_limit(self, server): + theirs = incoming(server, quantity="1000") + response = server["client"].post("/quotes", json=maker_body(theirs.id), headers=auth(server["trader"])) + assert response.status_code == 409 and response.json()["error"]["details"]["rule"] == "max_order_contracts" + + +# --------------------------------------------------------------------------- +# Grants +# --------------------------------------------------------------------------- + +class TestGrants: + async def test_a_viewer_may_read_but_not_write(self, server): + client, paper = server["client"], server["paper"] + rfq, quote = new_rfq(server) + theirs = incoming(server) + mine = client.post("/quotes", json=maker_body(theirs.id), headers=auth(server["trader"])).json() + viewer = auth(server["viewer"]) + writes = [ + ("post", "/rfqs", rfq_body()), + ("delete", f"/rfqs/{rfq['id']}", None), + ("post", f"/quotes/{quote['id']}/accept", accept_body(quote)), + ("post", "/quotes", maker_body(theirs.id)), + ("put", f"/quotes/{mine['id']}", maker_body(theirs.id, bid="0.41")), + ("delete", f"/quotes/{mine['id']}", None), + ("post", f"/quotes/{mine['id']}/confirm", {}), + ("post", "/combos", {"legs": legs()}), + ] + calls_before = list(paper.calls) + for method, path, body in writes: + response = client.request(method.upper(), path, json=body, headers=viewer) + assert response.status_code == 403, (method, path, response.text) + assert response.json()["error"]["code"] == "forbidden" + assert "may not trade" in response.json()["error"]["message"] + assert paper.calls == calls_before, "a refused write reaches no venue" + reads = ["/rfqs", f"/rfqs/{rfq['id']}", f"/rfqs/{rfq['id']}/quotes", "/rfqs/incoming", "/quotes"] + for path in reads: + assert client.get(path, headers=viewer).status_code == 200, path + + async def test_a_key_for_another_account_sees_nothing_here(self, server): + client = server["client"] + rfq, quote = new_rfq(server) + other = auth(server["other"]) + assert client.get("/rfqs", headers=other).json()["count"] == 0 + assert client.get("/quotes", headers=other).json()["count"] == 0 + assert client.get(f"/rfqs/{rfq['id']}", headers=other).status_code == 403 + assert client.get(f"/rfqs/{rfq['id']}/quotes", headers=other).status_code == 403 + assert client.get(f"/rfqs?venue={VENUE}", headers=other).status_code == 403 + refused = client.post(f"/quotes/{quote['id']}/accept", json=accept_body(quote), headers=other) + assert refused.status_code == 403 and ACCOUNT.key in refused.json()["error"]["message"] + assert client.post("/rfqs", json=rfq_body(), headers=other).status_code == 403 + + async def test_no_key_is_401(self, server): + assert server["client"].get("/rfqs").status_code == 401 + + +# --------------------------------------------------------------------------- +# Events and the halt +# --------------------------------------------------------------------------- + +class TestEvents: + async def test_the_socket_carries_rfq_quote_and_combo_events(self, server): + client = server["client"] + with client.websocket_connect(f"/ws/events?key={server['viewer']}&since=0&kinds=rfq,quote,combo") as socket: + client.post("/combos", json={"legs": legs()}, headers=auth(server["trader"])) + _, quote = new_rfq(server) + client.post(f"/quotes/{quote['id']}/accept", json=accept_body(quote), headers=auth(server["trader"])) + seen: list[dict] = [] + for _ in range(50): + seen.append(json.loads(socket.receive_text())) + if any(e["kind"] == "quote.accepted" for e in seen): + break + kinds = [e["kind"] for e in seen] + assert {"combo.saved", "rfq.created", "quote.accepted"} <= set(kinds), kinds + assert all(k.startswith(("rfq.", "quote.", "combo.")) for k in kinds), "the kinds filter holds on replay too" + accepted = next(e for e in seen if e["kind"] == "quote.accepted") + assert accepted["key"] == f"{VENUE}:quote:{quote['id']}" and accepted["payload"]["status"] == "accepted" + + async def test_a_pushed_incoming_rfq_reaches_the_socket_at_once(self, server): + """`rfq.incoming` goes from `engine.rfq.on_incoming` straight to the + socket's queue: no poll, no tick in between.""" + import time + + client, engine, paper = server["client"], server["engine"], server["paper"] + rfq = paper.add_incoming_rfq(MARKET, quantity=D("10")) + before = len(engine.bus.subscribers) + with client.websocket_connect(f"/ws/events?key={server['viewer']}&kinds=rfq.incoming") as socket: + for _ in range(200): + if len(engine.bus.subscribers) > before: + break + time.sleep(0.005) + start = time.perf_counter() + client.portal.call(engine.rfq.on_incoming, VENUE, rfq) + event = json.loads(socket.receive_text()) + elapsed_ms = (time.perf_counter() - start) * 1000 + assert event["kind"] == "rfq.incoming" and event["payload"]["id"] == rfq.id + assert elapsed_ms < 400, elapsed_ms + + async def test_replay_carries_quote_events(self, server): + client = server["client"] + _, quote = new_rfq(server) + await server["engine"].rfq.tick() # the follower sees the maker's quote: quote.received + with client.websocket_connect(f"/ws/events?key={server['viewer']}&since=0&kinds=quote") as socket: + event = json.loads(socket.receive_text()) + assert event["kind"] == "quote.received" and event["payload"]["id"] == quote["id"] + + +class TestHalt: + async def test_halt_cancels_our_rfqs_and_quotes(self, server): + client, paper = server["client"], server["paper"] + rfq, _ = new_rfq(server) + mine = client.post("/quotes", json=maker_body(incoming(server).id), headers=auth(server["trader"])).json() + halted = client.post("/halt", json={"reason": "stop", "policy": "cancel"}, headers=auth(server["trader"])) + assert halted.status_code == 200, halted.text + result = halted.json()["rfq"] + assert result["rfqs"][VENUE] == 1 and result["quotes"][VENUE] == 1 + assert paper.rfqs[rfq["id"]].status == RfqStatus.CLOSED + assert paper.quotes[mine["id"]].status == QuoteStatus.CANCELED + assert client.get("/rfqs?source=engine", headers=auth(server["viewer"])).json()["count"] == 0 + assert client.get("/quotes?status=open", headers=auth(server["viewer"])).json()["count"] == 0 + refused = client.post("/rfqs", json=rfq_body(), headers=auth(server["trader"])) + assert refused.status_code == 409 and refused.json()["error"]["details"]["rule"] == "kill_switch" + + +# --------------------------------------------------------------------------- +# The OpenAPI document +# --------------------------------------------------------------------------- + +class TestOpenAPI: + ROUTES = { + "/rfqs": {"get", "post"}, "/rfqs/incoming": {"get"}, "/rfqs/{rfq_id}": {"get", "delete"}, + "/rfqs/{rfq_id}/quotes": {"get"}, "/quotes": {"get", "post"}, "/quotes/{quote_id}": {"put", "delete"}, + "/quotes/{quote_id}/accept": {"post"}, "/quotes/{quote_id}/confirm": {"post"}, "/combos": {"post"}, + "/combos/lookup": {"post"}, "/combos/{market_id}": {"get"}, "/combos/markets": {"get"}, + "/combos/collections": {"get"}, + } + + async def test_the_routes_and_models_are_in_the_document(self, server): + document = server["client"].get("/openapi.json").json() + for path, methods in self.ROUTES.items(): + assert methods <= set(document["paths"].get(path, {})), path + schemas = document["components"]["schemas"] + assert {"RfqView", "RfqQuote", "QuoteResult", "Combo", "ComboLeg", "RfqBody", "AcceptBody", + "MakerQuoteBody", "Rfq", "Canceled"} <= set(schemas) + accept = document["paths"]["/quotes/{quote_id}/accept"]["post"]["responses"] + for code in ("200", "202"): + assert accept[code]["content"]["application/json"]["schema"]["$ref"].endswith("/QuoteResult") + assert {"409", "403", "404", "501"} <= set(accept) + created = document["paths"]["/rfqs"]["post"]["responses"]["201"] + assert created["content"]["application/json"]["schema"]["$ref"].endswith("/RfqView") + assert "rfq" in document["components"]["schemas"]["HaltResult"]["properties"] + assert "collection_id" in schemas["CreateComboBody"]["properties"] + assert {"market_id", "title", "event_id", "position_ids", "max_legs"} <= set(schemas["ComboMarket"]["properties"]) + params = {p["name"] for p in document["paths"]["/combos/markets"]["get"]["parameters"]} + assert {"venue", "event_id", "tag", "limit", "cursor"} <= params diff --git a/tests/test_server_rfq_group.py b/tests/test_server_rfq_group.py new file mode 100644 index 0000000..96f6707 --- /dev/null +++ b/tests/test_server_rfq_group.py @@ -0,0 +1,317 @@ +"""The cross-venue RFQ group routes under /trading, offline (docs/rfq-combo.md, "Cross-venue RFQ"). + +A real engine on three paper RFQ venues (the venues and the mocked matching +service of `test_engine_rfq_group`), a real control store, and FastAPI's +test client. Covered: create, list, get, accept and cancel; each grant +refusing (view for reads, trade on every venue the group asks for the +rest, a venue the key may not trade skipped as `forbidden`); 202 by default +and 200 when a waited-for accept is final; the double-accept 409; `400 +no_venue` with the reasons; the event socket; the halt; the OpenAPI document. +""" +from __future__ import annotations + +import json +from decimal import Decimal as D +from pathlib import Path + +import pytest + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.server.store import ControlStore +from synpath.server.trading import create_trading_app +from synpath.trading.types import Account + +from test_engine_rfq_group import K_A, K_B, matching, venues + +pytestmark = pytest.mark.anyio + +ACCOUNTS = {name: Account(venue=name, name="desk-a") for name in ("kalshi", "polymarket", "polymarket_us")} + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +@pytest.fixture(autouse=True) +def no_saved_key(monkeypatch, tmp_path): + monkeypatch.delenv("SYNPATH_API_KEY", raising=False) + monkeypatch.setenv("SYNPATH_CREDENTIALS_FILE", str(tmp_path / "no-credentials.json")) + + +@pytest.fixture +async def server(tmp_path: Path): + from fastapi.testclient import TestClient + + adapters = venues() + for name, venue in adapters.items(): + venue.account = ACCOUNTS[name] + engine = Engine( + adapters, + EngineConfig(journal_path=str(tmp_path / "engine.db"), require_lease=True, in_doubt_timeout_s=0, + rfq_poll_interval_s=0.01, rfq_incoming_interval_s=None), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None), + accounts=ACCOUNTS, matcher=matching(), + ) + await engine.start() + store = await ControlStore(str(tmp_path / "control.db")).open() + owner, _ = await store.bootstrap("owner") + await store.acting_as(f"user:{owner.id}", "setup") + + async def user(name: str, grants: dict[str, tuple[str, ...]]) -> str: + made = await store.create_user(name) + for venue, permissions in grants.items(): + for permission in permissions: + await store.grant(made.id, ACCOUNTS[venue].key, permission) + return (await store.issue_key(made.id)).secret + + everything = ("trade", "view") + keys = { + "trader": await user("trader", {v: everything for v in ACCOUNTS}), + "viewer": await user("viewer", {v: ("view",) for v in ACCOUNTS}), + "partial": await user("partial", {"kalshi": everything, "polymarket": everything}), + "outsider": await user("outsider", {"kalshi": everything}), + } + with TestClient(create_trading_app(engine, store)) as client: + yield {"client": client, "engine": engine, "adapters": adapters, **keys} + await store.close() + await engine.stop() + + +def auth(key: str) -> dict[str, str]: + return {"Authorization": f"Bearer {key}"} + + +def group_body(**kw) -> dict: + return {"instrument_id": K_A, "quantity": "10", "cross_venue": True, "book": "alpha", **kw} + + +def created(server, key: str = "trader", **kw) -> dict: + response = server["client"].post("/rfq-groups", json=group_body(**kw), headers=auth(server[key])) + assert response.status_code == 201, response.text + return response.json() + + +class TestCreateAndRead: + async def test_create_list_and_get(self, server): + client = server["client"] + group = created(server) + assert group["status"] == "open" and {m["venue"] for m in group["members"]} == set(ACCOUNTS) + assert [q["venue"] for q in group["quotes"]] == ["kalshi", "polymarket", "polymarket_us"] + best = group["quotes"][0] + assert best["rank"] == 1 and best["raw_price"] == "0.43" and best["fee"] == "0.18" + assert best["effective_price"] == "0.448" + listed = client.get("/rfq-groups", headers=auth(server["viewer"])) + assert listed.status_code == 200 and [g["id"] for g in listed.json()["data"]] == [group["id"]] + one = client.get(f"/rfq-groups/{group['id']}?refresh=true", headers=auth(server["viewer"])) + assert one.status_code == 200 and one.json()["id"] == group["id"] + assert client.get("/rfq-groups/nope", headers=auth(server["viewer"])).status_code == 404 + + async def test_a_venue_the_key_may_not_trade_is_skipped(self, server): + group = created(server, "partial") + assert {m["venue"] for m in group["members"]} == {"kalshi", "polymarket"} + assert [(s["venue"], s["code"]) for s in group["skipped"]] == [("polymarket_us", "forbidden")] + client = server["client"] + named = client.post("/rfq-groups", json=group_body(venues=["polymarket_us"]), headers=auth(server["partial"])) + assert named.status_code == 403 + assert client.post("/rfq-groups", json=group_body(), headers=auth(server["viewer"])).status_code == 403 + # A key that cannot see every venue of a group sees neither it nor its list row. + assert client.get(f"/rfq-groups/{group['id']}", headers=auth(server["outsider"])).status_code == 403 + assert client.get("/rfq-groups", headers=auth(server["outsider"])).json()["data"] == [] + + async def test_no_venue_left_is_400_with_the_reasons(self, server): + response = server["client"].post("/rfq-groups", json={ + "legs": [{"market_id": K_A, "outcome": "yes"}, {"market_id": K_B, "outcome": "no"}], + "quantity": "10", "venues": ["polymarket_us"]}, headers=auth(server["trader"])) + assert response.status_code == 400, response.text + body = response.json() + assert body["error"]["code"] == "no_venue" + assert [s["code"] for s in body["error"]["details"]["skipped"] if s["venue"] == "polymarket_us"] == ["missing_leg"] + + async def test_a_body_with_two_sizes_is_422(self, server): + response = server["client"].post("/rfq-groups", json=group_body(amount="5"), headers=auth(server["trader"])) + assert response.status_code == 422 + + +class TestAcceptAndCancel: + async def test_accept_is_202_then_200_with_wait_and_a_second_is_409(self, server): + client = server["client"] + group = created(server) + best = group["quotes"][0] + assert client.post(f"/rfq-groups/{group['id']}/accept", json={"quote_id": best["quote_id"], "venue": best["venue"]}, + headers=auth(server["viewer"])).status_code == 403 + assert client.post(f"/rfq-groups/{group['id']}/accept", json={"quote_id": best["quote_id"], "venue": best["venue"]}, + headers=auth(server["partial"])).status_code == 403, "it would cancel polymarket_us" + done = client.post(f"/rfq-groups/{group['id']}/accept", + json={"quote_id": best["quote_id"], "venue": best["venue"], "worst_price": "0.45", "wait": True}, + headers=auth(server["trader"])) + assert done.status_code == 200, done.text + body = done.json() + assert body["final"] and body["quote"]["status"] == "executed" and body["intent_state"] == "sent" + assert body["group"]["status"] == "executed" and body["order_id"] + assert {m["venue"]: m["status"] for m in body["group"]["members"]} == { + "kalshi": "executed", "polymarket": "lapsing", "polymarket_us": "canceled"} + again = client.post(f"/rfq-groups/{group['id']}/accept", json={"quote_id": group["quotes"][1]["quote_id"], "venue": group["quotes"][1]["venue"]}, + headers=auth(server["trader"])) + assert again.status_code == 409 and again.json()["error"]["details"]["rule"] == "rfq_group_accepted" + + async def test_accept_without_wait_is_202(self, server): + for venue in server["adapters"].values(): + venue.auto_advance = False + group = created(server) + sent = server["client"].post(f"/rfq-groups/{group['id']}/accept", + json={"quote_id": group["quotes"][0]["quote_id"], "venue": group["quotes"][0]["venue"]}, headers=auth(server["trader"])) + assert sent.status_code == 202, sent.text + assert sent.json()["group"]["status"] == "accepted" and not sent.json()["final"] + refused = server["client"].delete(f"/rfq-groups/{group['id']}", headers=auth(server["trader"])) + assert refused.status_code == 409 and refused.json()["error"]["details"]["rule"] == "rfq_group_accept_in_flight" + + async def test_accept_names_the_venue_when_quote_ids_collide(self, server): + client = server["client"] + group = created(server) + ids_by_venue = {q["venue"]: q["quote_id"] for q in group["quotes"]} + assert len(set(ids_by_venue.values())) == 1, "the paper venues return the same quote id" + quote_id = ids_by_venue["polymarket_us"] + missing = client.post(f"/rfq-groups/{group['id']}/accept", json={"quote_id": quote_id}, + headers=auth(server["trader"])) + assert missing.status_code == 400, missing.text + error = missing.json()["error"] + assert error["code"] == "venue_required" and "name the quote's venue" in error["message"] + assert error["details"]["venues"] == ["kalshi", "polymarket", "polymarket_us"] + outside = client.post(f"/rfq-groups/{group['id']}/accept", json={"quote_id": quote_id, "venue": "limitless"}, + headers=auth(server["trader"])) + assert outside.status_code == 400 and outside.json()["error"]["code"] == "venue_not_in_group" + done = client.post(f"/rfq-groups/{group['id']}/accept", + json={"quote_id": quote_id, "venue": "polymarket_us", "wait": True}, + headers=auth(server["trader"])) + assert done.status_code == 200, done.text + assert done.json()["venue"] == "polymarket_us" and done.json()["group"]["accepted_venue"] == "polymarket_us" + assert server["adapters"]["kalshi"].calls.count("accept_quote") == 0 + + async def test_cancel_all(self, server): + client = server["client"] + group = created(server) + assert client.delete(f"/rfq-groups/{group['id']}", headers=auth(server["viewer"])).status_code == 403 + canceled = client.delete(f"/rfq-groups/{group['id']}", headers=auth(server["trader"])) + assert canceled.status_code == 200 and canceled.json()["status"] == "canceled" + assert {m["status"] for m in canceled.json()["members"]} == {"canceled", "lapsing"} + accept = client.post(f"/rfq-groups/{group['id']}/accept", json={"quote_id": group["quotes"][0]["quote_id"], "venue": group["quotes"][0]["venue"]}, + headers=auth(server["trader"])) + assert accept.status_code == 400 + + async def test_halt_cancels_open_groups(self, server): + client = server["client"] + group = created(server) + halted = client.post("/halt", json={"reason": "stop", "policy": "cancel"}, headers=auth(server["trader"])) + assert halted.status_code == 200, halted.text + assert halted.json()["rfq"]["groups"][group["id"]]["status"] == "canceled" + after = client.get(f"/rfq-groups/{group['id']}", headers=auth(server["viewer"])).json() + assert after["status"] == "canceled" + + +class Catalogue: + """The server's read adapter for one venue: YES quotes by market id.""" + + def __init__(self, quotes: dict[str, tuple[float, float]]): + self.quotes = quotes + + def fetch_markets_by_ids(self, market_ids): + from synpath.types import Market, Outcome, Quote + + return [Market(id=m, venue=m.partition(":")[0], venue_market_id=m.partition(":")[2], title=m, status="open", + yes=Outcome(label="Yes", quote=Quote(bid=self.quotes[m][0], ask=self.quotes[m][1])), + no=Outcome(label="No")) for m in market_ids if m in self.quotes] + + +class Registry: + def __init__(self, venues: dict[str, Catalogue]): + self.venues = venues + + def get(self, venue: str): + if venue not in self.venues: + raise KeyError(venue) + return self.venues[venue] + + +LEGS = [{"market_id": K_A, "outcome": "yes"}, {"market_id": K_B, "outcome": "no"}] + + +class TestPreview: + async def test_offered_venues_with_leg_prices_and_skipped_ones(self, server): + client = server["client"] + client.app.state.registry = Registry({ + "kalshi": Catalogue({K_A: (0.58, 0.60), K_B: (0.30, 0.32)}), + "polymarket": Catalogue({"polymarket:111": (0.57, 0.59)}), + }) + answer = client.post("/rfq-groups/preview", json={"legs": LEGS}, headers=auth(server["trader"])) + assert answer.status_code == 200, answer.text + body = answer.json() + offered = {v["venue"]: v for v in body["offered"]} + assert set(offered) == {"kalshi", "polymarket"} + # Kalshi: YES A at its ask 0.60, NO B at 1 - its bid 0.30. + assert offered["kalshi"]["leg_prices"] == ["0.6000", "0.7000"] and offered["kalshi"]["combined_price"] == "0.420000" + assert offered["kalshi"]["collection_id"] == "KXMVE-1" + # Polymarket has no quote for 222: no estimate. + assert offered["polymarket"]["leg_prices"] == ["0.5900", None] and offered["polymarket"]["combined_price"] is None + assert [(s["venue"], s["code"]) for s in body["skipped"]] == [("polymarket_us", "missing_leg")] + assert client.get("/rfq-groups", headers=auth(server["trader"])).json()["data"] == [] + + async def test_a_venue_the_key_may_not_trade_is_forbidden_and_bad_bodies_are_422(self, server): + client = server["client"] + answer = client.post("/rfq-groups/preview", json={"legs": LEGS}, headers=auth(server["partial"])).json() + assert ("polymarket_us", "forbidden") in [(s["venue"], s["code"]) for s in answer["skipped"]] + assert all(v["leg_prices"] is not None for v in answer["offered"]) # no registry: every price unknown + assert client.post("/rfq-groups/preview", json={}, headers=auth(server["trader"])).status_code == 422 + both = {"legs": LEGS, "quantity": "1", "amount": "1"} + assert client.post("/rfq-groups/preview", json=both, headers=auth(server["trader"])).status_code == 422 + unknown = client.post("/rfq-groups/preview", json={"legs": LEGS, "venues": ["nowhere"]}, + headers=auth(server["trader"])) + assert unknown.status_code == 404 + + +class TestBoard: + async def test_a_listed_venues_board(self, server): + response = server["client"].get("/combos/board?venue=polymarket", headers=auth(server["viewer"])) + assert response.status_code == 200, response.text + body = response.json() + assert body["venue"] == "polymarket" and body["count"] == 2 and body["complete"] + assert [e["kind"] for e in body["events"]] == ["question", "question"] + assert body["events"][0]["yes"]["market_id"] in ("polymarket:111", "polymarket:222") + + async def test_kalshi_without_a_catalogue_is_empty_and_a_venue_without_combos_is_400(self, server): + client = server["client"] + kalshi = client.get("/combos/board?venue=kalshi", headers=auth(server["viewer"])).json() + assert kalshi["events"] == [] and not kalshi["complete"] + assert client.get("/combos/board?venue=nowhere", headers=auth(server["viewer"])).status_code in (400, 404) + + +class TestEventsAndSchema: + async def test_the_event_socket_carries_group_events(self, server): + client = server["client"] + group = created(server) + with client.websocket_connect(f"/ws/events?key={server['viewer']}&since=0&kinds=rfq_group") as socket: + seen = [] + for _ in range(50): + seen.append(json.loads(socket.receive_text())) + if seen[-1]["kind"] == "rfq_group.updated": + break + kinds = [e["kind"] for e in seen] + assert kinds[0] == "rfq_group.created" and "rfq_group.quote" in kinds + assert all(k.startswith("rfq_group.") for k in kinds) + assert all(e["payload"]["group_id"] == group["id"] for e in seen) + + async def test_openapi(self, server): + document = server["client"].get("/openapi.json").json() + paths = document["paths"] + assert {"get", "post"} <= set(paths["/rfq-groups"]) + assert {"get", "delete"} <= set(paths["/rfq-groups/{group_id}"]) + accept = paths["/rfq-groups/{group_id}/accept"]["post"]["responses"] + for code in ("200", "202"): + assert accept[code]["content"]["application/json"]["schema"]["$ref"].endswith("/GroupAcceptResult") + assert {"403", "404", "409"} <= set(accept) + made = paths["/rfq-groups"]["post"]["responses"]["201"] + assert made["content"]["application/json"]["schema"]["$ref"].endswith("/RfqGroup") + schemas = document["components"]["schemas"] + assert {"RfqGroup", "GroupMember", "GroupQuote", "SkippedVenue", "RfqGroupBody", "GroupAcceptBody", + "GroupPreview", "PreviewVenue", "RfqGroupPreviewBody", "ComboBoard", "BoardEvent"} <= set(schemas) + assert "post" in paths["/rfq-groups/preview"] and "get" in paths["/combos/board"] diff --git a/tests/test_server_trading.py b/tests/test_server_trading.py index 0839e8e..a3a857b 100644 --- a/tests/test_server_trading.py +++ b/tests/test_server_trading.py @@ -226,7 +226,7 @@ async def test_place_amend_cancel(self, server): async def test_a_risk_rule_answers_409_with_its_name(self, server): client, engine = server["client"], server["engine"] - engine.risk.config = engine.risk.config.model_copy(update={"max_order_contracts": D("1")}) + engine.order_risk_checks.config = engine.order_risk_checks.config.model_copy(update={"max_order_contracts": D("1")}) response = client.post("/orders", json=order_body(quantity="5"), headers=auth(server["trader_key"])) error = response.json()["error"] assert response.status_code == 409 and error["code"] == "risk_rejected" @@ -256,13 +256,17 @@ async def test_portfolio_routes(self, server): pnl = client.get("/pnl", params={"level": "book"}, headers=auth(server["trader_key"])).json() assert pnl["level"] == "book" and "total" in pnl - async def test_fair_values_round_trip(self, server): + + async def test_the_order_log_and_the_journal_read_back_what_happened(self, server): client = server["client"] - put = client.put("/fair-values", json={"account": ACCOUNT.key, "market_id": SYM, "value": "0.51"}, - headers=auth(server["trader_key"])) - assert put.status_code == 200 and put.json()["value"] == "0.51" - listed = client.get("/fair-values", headers=auth(server["viewer_key"])).json() - assert listed == [{"account": ACCOUNT.key, "market_id": SYM, "value": "0.51"}] + placed = client.post("/orders", json=order_body(), headers=auth(server["trader_key"])) + assert placed.status_code == 201, placed.text + order_id = placed.json()["id"] + log = client.get("/order-log", params={"order_id": order_id}, headers=auth(server["viewer_key"])).json() + assert log["data"] and all(row["order_id"] in (order_id, None) or row["client_order_id"] for row in log["data"]) + assert "intent.planned" in [row["kind"] for row in log["data"]] + journal = client.get("/journal", params={"kind": "engine."}, headers=auth(server["viewer_key"])).json() + assert [row["kind"] for row in journal["data"]][-1] == "engine.started" async def test_halt_blocks_trading_and_resume_lifts_it(self, server): client = server["client"] @@ -535,14 +539,20 @@ async def refuse(request): after = server["client"].get("/orders", headers=auth(server["trader_key"])) assert after.status_code == 200, "the server keeps answering" - async def test_an_unreachable_venue_is_a_retryable_502(self, server, monkeypatch): + async def test_an_unreachable_venue_leaves_the_order_in_doubt_202(self, server, monkeypatch): + """No verdict from the venue is not a failure: the send may have + landed. 202 with its own code, retryable under the same id.""" from synpath.errors import ExchangeNotAvailable async def down(request): raise ExchangeNotAvailable("kalshi: 503 from the venue") monkeypatch.setattr(server["paper"], "create_order", down) - failed = server["client"].post("/orders", json=order_body(), headers=auth(server["trader_key"])) - assert failed.status_code == 502 and failed.json()["error"]["details"]["retryable"] is True + failed = server["client"].post("/orders", json=order_body(client_order_id="down-1"), + headers=auth(server["trader_key"])) + assert failed.status_code == 202, failed.text + error = failed.json()["error"] + assert error["code"] == "submission_in_doubt" and error["details"]["retryable"] is True + assert error["details"]["client_order_id"] == "down-1" and error["details"]["venue"] == "kalshi" async def test_accounts_lists_a_configured_venue_without_a_named_account(self, tmp_path): from fastapi.testclient import TestClient @@ -555,7 +565,7 @@ async def test_accounts_lists_a_configured_venue_without_a_named_account(self, t try: with TestClient(create_trading_app(engine, store)) as client: rows = client.get("/accounts", headers=auth(key.secret)).json() - assert [r["account"] for r in rows] == ["kalshi:default"] + assert [r["account"] for r in rows] == ["kalshi:paper"] # the adapter's actual execution identity finally: await store.close() await engine.stop() diff --git a/tests/test_smarkets_ws.py b/tests/test_smarkets_ws.py index d59cfe0..15e4ba2 100644 --- a/tests/test_smarkets_ws.py +++ b/tests/test_smarkets_ws.py @@ -92,13 +92,13 @@ def message(self, quotes, **extra): async def test_a_snapshot_then_only_what_changed(self): s, _ = stream() await s.watch_order_book([FULHAM]) - first = books(s.handle(self.message(QUOTES))) + first = books(s._handle_decoded_message(self.message(QUOTES))) assert [e.kind for e in first] == ["snapshot"] assert first[0].best_bid == Decimal("0.5") and first[0].best_ask == Decimal("0.5155") assert first[0].info["delayed"] is True - assert s.handle(self.message(QUOTES)) == [] + assert s._handle_decoded_message(self.message(QUOTES)) == [] changed = moved(QUOTES, "456060183", offers=[{"price": 5128, "quantity": 20000}]) - delta = books(s.handle(self.message(changed))) + delta = books(s._handle_decoded_message(self.message(changed))) assert [e.kind for e in delta] == ["delta"] assert {(level.price, level.size) for level in delta[0].asks} == { (Decimal("0.5128"), Decimal("2")), (Decimal("0.5155"), Decimal("0")), @@ -110,7 +110,7 @@ async def test_a_whole_market_keeps_every_contract(self): s, api = stream() await s._open() await s.watch_order_book(["smarkets:164973231"]) - events = s.handle(await s.poll()) + events = s._handle_decoded_message(await s.poll()) assert "/v3/markets/164973231/contracts/" in api.paths() assert {e.market_id for e in books(events)} == { "smarkets:164973231-456060183", "smarkets:164973231-456060184", "smarkets:164973231-456060185", @@ -119,20 +119,20 @@ async def test_a_whole_market_keeps_every_contract(self): async def test_a_ticker_fires_when_the_top_moves(self): s, _ = stream() await s.watch_ticker([FULHAM]) - first = s.handle(self.message(QUOTES)) + first = s._handle_decoded_message(self.message(QUOTES)) assert len(first) == 1 and isinstance(first[0], QuoteEvent) and first[0].bid == Decimal("0.5") - assert s.handle(self.message(QUOTES)) == [] + assert s._handle_decoded_message(self.message(QUOTES)) == [] deeper = moved(QUOTES, "456060183", bids=QUOTES["456060183"]["bids"][:1]) - assert s.handle(self.message(deeper)) == [] # only a lower level went - assert len(s.handle(self.message(moved(QUOTES, "456060183", bids=[])))) == 1 + assert s._handle_decoded_message(self.message(deeper)) == [] # only a lower level went + assert len(s._handle_decoded_message(self.message(moved(QUOTES, "456060183", bids=[])))) == 1 async def test_after_a_drop_the_next_poll_is_a_snapshot_and_resynced(self): s, _ = stream() await s.watch_order_book([FULHAM]) - s.handle(self.message(QUOTES)) + s._handle_decoded_message(self.message(QUOTES)) s.on_disconnect() assert not s.book(FULHAM).ready - assert [e.kind for e in books(s.handle(self.message(QUOTES)))] == ["snapshot"] + assert [e.kind for e in books(s._handle_decoded_message(self.message(QUOTES)))] == ["snapshot"] status = s._queue.get_nowait() assert isinstance(status, StreamStatusEvent) and status.state == "resynced" @@ -150,21 +150,21 @@ async def test_contract_states_are_read_every_few_polls_and_passed_on_change(sel await s._open() market = HALTED["markets"][0]["id"] await s.watch_market_status([f"smarkets:{market}"]) - first = s.handle(await s.poll()) + first = s._handle_decoded_message(await s.poll()) statuses = [e for e in first if isinstance(e, MarketStatusEvent)] assert {e.state for e in statuses} == {"paused"} and len(statuses) == 2 assert f"/v3/markets/{market}/" in api.paths() reads = len(api.requests) - assert s.handle(await s.poll()) == [] and len(api.requests) == reads + 1 # quotes only - s.handle(await s.poll()) - assert s.handle(await s.poll()) == [] # read again, unchanged + assert s._handle_decoded_message(await s.poll()) == [] and len(api.requests) == reads + 1 # quotes only + s._handle_decoded_message(await s.poll()) + assert s._handle_decoded_message(await s.poll()) == [] # read again, unchanged async def test_settled_contracts_carry_their_result(self): s, _ = stream() await s._open() market = SETTLED["markets"][0]["id"] await s.watch_market_status([f"smarkets:{market}"]) - events = [e for e in s.handle(await s.poll()) if isinstance(e, MarketStatusEvent)] + events = [e for e in s._handle_decoded_message(await s.poll()) if isinstance(e, MarketStatusEvent)] assert sorted(e.result for e in events) == ["no", "no", "yes"] assert all(e.state == "settled" for e in events) diff --git a/tests/test_stop_lifecycle.py b/tests/test_stop_lifecycle.py new file mode 100644 index 0000000..71dd80c --- /dev/null +++ b/tests/test_stop_lifecycle.py @@ -0,0 +1,626 @@ +"""Stop transactions, retries, races and recovery using fake venues only. + +No test constructs a credentialed adapter. Crash tests use a file-backed +fake venue, so acceptance survives an abruptly killed worker. +""" +from __future__ import annotations + +import asyncio +import json +import os +import sqlite3 +import subprocess +import sys +from decimal import Decimal as D +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from synpath.engine import Engine, EngineConfig, RiskConfig +from synpath.engine.submissions import SubmissionPending +from synpath.errors import BadRequest, NetworkError +from synpath.trading.types import OrderRequest, OrderStatus, Precision +from synpath.trading.instruments import MarketSpec +from synpath.types import Page + +from test_engine import ScriptedVenue +from test_engine_orders import setup, book, feed_book, request + +pytestmark = pytest.mark.anyio + + +@pytest.fixture +def anyio_backend(): + return "asyncio" + + +async def scripted_engine(path, venue=None, **config): + venue = venue or ScriptedVenue() + engine = Engine({"kalshi": venue}, EngineConfig(journal_path=str(path), **config), + risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None)) + await engine.start() + # Above the sell stops at 0.40: a stop reads the book it is handed at + # once, so a test triggers it deliberately, with `cross`. + engine.set_book("kalshi:KX-A", SimpleNamespace(ready=True, best_bid=D("0.45"), best_ask=D("0.47"))) + return engine, venue + + +async def cross(engine, market_id="kalshi:KX-A"): + """The bid falls through the sell stops at 0.40, and the engine hears of it.""" + engine.set_book(market_id, SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.41"))) + await engine.on_book({"market_id": market_id}) + + +def stop_request(**changes): + base = dict(market_id="kalshi:KX-A", side="sell", quantity="10", type="stop_market", + stop_price="0.40", client_order_id="protect-a") + return OrderRequest(**{**base, **changes}) + + +async def test_retry_same_stop_returns_original_parent_even_after_restart(tmp_path): + path = tmp_path / "engine.db" + engine, venue = await scripted_engine(path) + original = await engine.submit(stop_request()) + repeated = await engine.submit(stop_request(quantity="10.000", stop_price="0.400")) + assert repeated.id == original.id and len(engine.managed_order_controller.parents) == 1 + await engine.stop() + restarted, _ = await scripted_engine(path, venue) + try: + assert (await restarted.submit(stop_request())).id == original.id + assert venue.sent == [] + finally: + await restarted.stop() + + +async def test_concurrent_retries_create_one_parent_and_conflicts_do_not_send(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + try: + parents = await asyncio.gather(*(engine.submit(stop_request()) for _ in range(12))) + assert len({parent.id for parent in parents}) == 1 + with pytest.raises(BadRequest, match="different instruction"): + await engine.submit(stop_request(quantity="11")) + with pytest.raises(BadRequest, match="different instruction"): + await engine.submit(stop_request(type="stop_limit", price="0.38")) + assert venue.sent == [] + finally: + await engine.stop() + + +async def test_ordinary_retry_and_lost_response_never_send_twice(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + req = OrderRequest(market_id="kalshi:KX-A", side="buy", quantity="1", price="0.40", client_order_id="limit-a") + try: + first = await engine.submit(req) + assert (await engine.submit(req)).id == first.id + assert len(venue.sent) == 1 + venue.fail_next = "arrived" + uncertain = req.model_copy(update={"client_order_id": "limit-b"}) + with pytest.raises(NetworkError): + await engine.submit(uncertain) + recovered = await engine.submit(uncertain) + assert recovered.client_order_id == "limit-b" and len(venue.sent) == 2 + finally: + await engine.stop() + + +async def test_an_unresolved_retry_stays_pending_without_another_send(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db", lost_after_s=0) + req = OrderRequest(market_id="kalshi:KX-A", side="buy", quantity="1", price="0.40", client_order_id="lost-a") + try: + venue.fail_next = "lost" + with pytest.raises(NetworkError): + await engine.submit(req) + for _ in range(3): + with pytest.raises(SubmissionPending): + await engine.submit(req) + assert venue.sent == ["lost-a"] + finally: + await engine.stop() + + +async def test_day_retry_keeps_the_original_expiry(setup): + engine, paper, clock = setup + req = request(side="sell", type="stop_market", stop_price="0.40", time_in_force="day", client_order_id="day-a") + first = await engine.submit(req) + expiry = engine.managed_order_controller.get(first.id).request.expires_at + clock.tick(86400) + repeated = await engine.submit(req) + assert repeated.id == first.id and repeated.expires_at == expiry + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(first.id).state == "expired" and paper.fills == [] + + +async def test_expiry_wins_when_price_arrives_before_the_timer(setup): + engine, paper, clock = setup + expires = int((clock() + 1) * 1000) + parent = await engine.submit(request(side="sell", type="stop_market", stop_price="0.40", time_in_force="gtd", expires_at=expires)) + clock.tick(1) + book(paper, engine, bids=[(D("0.39"), D("100"))], asks=[(D("0.41"), D("100"))]) + await feed_book(engine, paper=paper) + restored = engine.managed_order_controller.get(parent.id) + assert restored.state == "expired" and restored.children == [] + + +async def test_trigger_and_child_plan_are_committed_before_adapter_call(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + parent = await engine.submit(stop_request()) + original = venue.create_order + async def inspect(request): + snapshot = (await engine.journal.managed_one(parent.id))[1] + intent = await engine.journal.intent(request.client_order_id) + assert snapshot["state"] == "working" and snapshot["extra"]["pending_child"]["client_order_id"] == request.client_order_id + assert intent.state == "sending" and request.tags["parent"] == parent.id + return await original(request) + venue.create_order = inspect + try: + await cross(engine) + assert len(venue.sent) == 1 + finally: + await engine.stop() + + +async def test_atomic_plan_rolls_back_both_parent_and_child(setup, monkeypatch): + engine, paper, clock = setup + parent = await engine.submit(request(side="sell", type="stop_market", stop_price="0.40")) + managed = engine.managed_order_controller.get(parent.id) + snapshot = {**managed.snapshot(), "state": "working"} + child = managed.child_request(quantity=D("10"), price=D("0.01"), type="market", time_in_force="ioc") + child = child.model_copy(update={"client_order_id": "child-a"}) + original = engine.journal._write_intent + async def fail(*args, **kwargs): + await original(*args, **kwargs) + raise RuntimeError("interrupt transaction") + monkeypatch.setattr(engine.journal, "_write_intent", fail) + with pytest.raises(RuntimeError): + await engine.journal.plan_managed_child(snapshot, child, venue=managed.venue, account=managed.account) + assert (await engine.journal.managed_one(parent.id))[1]["state"] == "waiting" + assert await engine.journal.intent("child-a") is None + assert await engine.journal.typed_order("market", "kalshi", "child-a") is None + assert paper.fills == [] + + +async def test_lost_child_response_stays_triggered_and_is_adopted_after_restart(tmp_path): + path = tmp_path / "engine.db" + engine, venue = await scripted_engine(path) + parent = await engine.submit(stop_request()) + venue.fail_next = "arrived" + await cross(engine) + assert engine.managed_order_controller.get(parent.id).state == "working" and len(venue.sent) == 1 + await engine.stop() + restarted, _ = await scripted_engine(path, venue) + try: + restored = restarted.managed_order_controller.get(parent.id) + assert len(restored.children) == 1 and restored.pending_child is None + for _ in range(3): + await restarted.managed_order_controller.on_timer() + await cross(restarted) + assert len(venue.sent) == 1 + assert restored.children[0].order_id == "V1" + finally: + await restarted.stop() + + +async def test_stop_market_uses_deep_available_liquidity_and_reports_execution_average(setup): + engine, paper, clock = setup + parent = await engine.submit(request(side="sell", type="stop_market", stop_price="0.40")) + book(paper, engine, bids=[(D("0.39"), D("4")), (D("0.20"), D("6"))], asks=[(D("0.41"), D("10"))]) + await feed_book(engine, paper=paper) + result = engine.managed_order_controller.get(parent.id).as_order() + assert result.status == OrderStatus.CLOSED and result.filled == D("10") + assert result.average_price == D("0.276"), "the fill average is not the 0.01 child limit" + child = (await engine.journal.orders_for_parent(parent.id))[0] + assert child.price == D("0.01") and child.parent_id == parent.id + + +async def test_registered_market_precision_controls_the_persisted_price_bound(setup): + engine, paper, clock = setup + specification = MarketSpec(market_id="kalshi:KX-A", venue="kalshi", precision=Precision(tick=D("0.001")), tradable=True, status="open") + engine.market_master.put([specification]) + parent = await engine.submit(request(type="stop_market", stop_price="0.50")) + book(paper, engine, bids=[(D("0.49"), D("10"))], asks=[(D("0.51"), D("10"))]) + await feed_book(engine, paper=paper) + child = (await engine.journal.orders_for_parent(parent.id))[0] + assert child.price == D("0.999") + + +async def test_cancel_and_trigger_race_leaves_no_working_child(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + parent = await engine.submit(stop_request(type="stop_limit", price="0.38")) + try: + await asyncio.wait_for(asyncio.gather(cross(engine), engine.cancel(parent.id)), 2) + assert engine.managed_order_controller.get(parent.id).state == "canceled" + assert await venue.fetch_open_orders() == [] + assert len(venue.sent) <= 1 + finally: + await engine.stop() + + +async def test_cancel_acknowledgement_with_nonterminal_result_stays_pending(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + parent = await engine.submit(stop_request(type="stop_limit", price="0.38")) + await cross(engine) + original = venue.cancel_order + async def acknowledge(order_id, **kwargs): + return venue.orders[order_id] + venue.cancel_order = acknowledge + try: + result = await engine.cancel(parent.id) + assert result.status == OrderStatus.PENDING_CANCEL + venue.cancel_order = original + await engine.managed_order_controller.on_timer() + assert engine.managed_order_controller.get(parent.id).state == "canceled" + finally: + await engine.stop() + + +async def test_order_history_pages_are_exhausted_before_adoption(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + parent = await engine.submit(stop_request()) + venue.fail_next = "arrived" + await cross(engine) + child = venue.orders["V1"].model_copy(update={"status": OrderStatus.CLOSED, "filled": D("10"), "average_price": D("0.39")}) + venue.orders["V1"] = child + cursors = [] + async def history(**kwargs): + cursors.append(kwargs.get("cursor")) + return Page([], next_cursor="next") if kwargs.get("cursor") is None else Page([child]) + venue.fetch_orders = history + try: + await engine.managed_order_controller.on_timer() + assert cursors == [None, "next"] + assert engine.managed_order_controller.get(parent.id).state == "done" and len(venue.sent) == 1 + finally: + await engine.stop() + + +async def test_no_child_dispatch_after_lease_loss(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + try: + with sqlite3.connect(engine.config.journal_path) as connection: + connection.execute("UPDATE leases SET owner='replacement'") + from synpath.engine import LeaseLost + with pytest.raises(LeaseLost): + await engine.submit(stop_request()) + assert venue.sent == [] + finally: + await engine.stop() + + +class DurableFakeVenue(ScriptedVenue): + """Acceptance is durable outside the worker process, like a real venue.""" + def __init__(self, path): + super().__init__() + self.path = Path(path) + if self.path.exists(): + from synpath.trading.types import Order + self.orders = {row["id"]: Order.model_validate(row) for row in json.loads(self.path.read_text())} + async def create_order(self, request): + order = await super().create_order(request) + with self.path.open("w") as file: + json.dump([value.model_dump(mode="json") for value in self.orders.values()], file) + file.flush() + os.fsync(file.fileno()) + return order + + +@pytest.mark.parametrize("boundary", ["before_send", "after_acceptance", "after_response"]) +async def test_hard_crash_restores_one_child_at_each_network_boundary(tmp_path, boundary): + journal = tmp_path / "worker.db" + venue_path = tmp_path / "fake-venue.json" + script = ''' +import asyncio, os +from test_stop_lifecycle import scripted_engine, stop_request, DurableFakeVenue, cross +async def crash(): + engine, venue = await scripted_engine(JOURNAL, DurableFakeVenue(VENUE)) + await engine.submit(stop_request()) + if BOUNDARY == 'before_send': + original = engine.journal.mark_intent + async def stop_before_mark(*args, **kwargs): + if args[1] == 'sending': + os._exit(77) + return await original(*args, **kwargs) + engine.journal.mark_intent = stop_before_mark + elif BOUNDARY == 'after_acceptance': + original = venue.create_order + async def accept_then_stop(request): + await original(request) + os._exit(77) + venue.create_order = accept_then_stop + else: + original = engine.journal.upsert_order + async def stop_before_record(order, **kwargs): + if order.held_by.value == 'venue': + os._exit(77) + return await original(order, **kwargs) + engine.journal.upsert_order = stop_before_record + await cross(engine) +asyncio.run(crash()) +''' + script = f'JOURNAL={str(journal)!r}\nVENUE={str(venue_path)!r}\nBOUNDARY={boundary!r}\n' + script + environment = dict(os.environ) + environment['PYTHONPATH'] = str(Path(__file__).parent) + os.pathsep + environment.get('PYTHONPATH', '') + completed = subprocess.run([sys.executable, '-c', script], env=environment, timeout=15, capture_output=True, text=True) + assert completed.returncode == 77, completed.stderr + with sqlite3.connect(journal) as connection: + connection.execute("UPDATE leases SET expires_ts=0") + venue = DurableFakeVenue(venue_path) + engine, _ = await scripted_engine(journal, venue) + try: + parent = engine.managed_order_controller.live()[0] + assert len(parent.children) == 1 and parent.pending_child is None + assert len(venue.orders) == 1 + for _ in range(2): + await engine.managed_order_controller.on_timer() + assert len(venue.orders) == 1 + assert len(venue.sent) == (1 if boundary == 'before_send' else 0) + finally: + await engine.stop() + + +async def test_legacy_checkpoint_client_key_is_reserved_during_migration(tmp_path): + path = tmp_path / "old.db" + engine, venue = await scripted_engine(path) + original = await engine.submit(stop_request()) + await engine.stop() + with sqlite3.connect(path) as connection: + connection.execute("DELETE FROM submissions") + connection.execute("DELETE FROM meta WHERE key='submissions_version'") + restarted, _ = await scripted_engine(path, venue) + try: + assert (await restarted.submit(stop_request())).id == original.id + assert len(restarted.managed_order_controller.parents) == 1 and venue.sent == [] + finally: + await restarted.stop() + + +async def test_cancel_a_planned_child_never_dispatches_it(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + parent = await engine.submit(stop_request()) + managed = engine.managed_order_controller.get(parent.id) + managed.state = "working" + child = managed.child_request(quantity=D("10"), price=D("0.01"), type="market", time_in_force="ioc") + child = child.model_copy(update={"client_order_id": "planned-child"}) + managed.pending_child = child + await engine.journal.plan_managed_child(managed.snapshot(), child, venue=managed.venue, account=managed.account) + try: + result = await engine.cancel(parent.id) + assert result.status == OrderStatus.CANCELED and venue.sent == [] + assert (await engine.journal.intent(child.client_order_id)).state == "settled" + from synpath.trading.errors import OrderRejected + with pytest.raises(OrderRejected): + await engine.submit(child) + assert venue.sent == [] + finally: + await engine.stop() + + +async def test_expired_planned_stop_limit_does_not_send_after_restart(tmp_path): + path = tmp_path / "engine.db" + engine, venue = await scripted_engine(path) + parent = await engine.submit(stop_request(type="stop_limit", price="0.38", time_in_force="gtd", expires_at=int((engine.clock() + 60) * 1000))) + managed = engine.managed_order_controller.get(parent.id) + managed.state = "working" + managed.pending_child = managed.child_request(quantity=D("10"), price=D("0.38"), type="limit", time_in_force="gtd", expires_at=managed.request.expires_at).model_copy(update={"client_order_id": "planned-expiry"}) + await engine.journal.plan_managed_child(managed.snapshot(), managed.pending_child, venue=managed.venue, account=managed.account) + await engine.stop() + config = EngineConfig(journal_path=str(path)) + restarted = Engine({"kalshi": venue}, config, clock=lambda: managed.request.expires_at / 1000 + 1) + try: + await restarted.start() + assert restarted.managed_order_controller.get(parent.id).state == "expired" + assert venue.sent == [] + finally: + await restarted.stop() + + +async def test_cancellation_of_uncertain_child_survives_restart(tmp_path): + path = tmp_path / "engine.db" + engine, venue = await scripted_engine(path) + parent = await engine.submit(stop_request(type="stop_limit", price="0.38")) + venue.fail_next = "arrived" + await cross(engine) + original = venue.fetch_open_orders + async def unavailable(**kwargs): + raise NetworkError("fake venue unavailable") + venue.fetch_open_orders = unavailable + canceled = await engine.cancel(parent.id) + assert canceled.status == OrderStatus.PENDING_CANCEL + await engine.stop() + venue.fetch_open_orders = original + restarted, _ = await scripted_engine(path, venue) + try: + assert restarted.managed_order_controller.get(parent.id).state == "canceled" + assert await venue.fetch_open_orders() == [] and len(venue.sent) == 1 + finally: + await restarted.stop() + + +async def test_a_fill_that_wins_the_cancel_race_closes_the_parent(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + parent = await engine.submit(stop_request(type="stop_limit", price="0.38")) + await cross(engine) + async def already_filled(order_id, **kwargs): + done = venue.orders[order_id].model_copy(update={"filled": D("10"), "status": OrderStatus.CLOSED, "average_price": D("0.39")}) + venue.orders[order_id] = done + return done + venue.cancel_order = already_filled + try: + result = await engine.cancel(parent.id) + assert result.status == OrderStatus.CLOSED and result.filled == D("10") + finally: + await engine.stop() + + +async def test_crossed_book_and_replayed_trade_do_not_trigger(setup): + engine, paper, clock = setup + parent = await engine.submit(request(side="sell", type="stop_market", stop_price="0.40")) + engine.set_book("kalshi:KX-A", SimpleNamespace(ready=True, best_bid=D("0.39"), best_ask=D("0.30"))) + await engine.on_book({"market_id": "kalshi:KX-A"}) + assert engine.managed_order_controller.get(parent.id).state == "waiting" + last = await engine.submit(request(side="sell", type="stop_market", stop_price="0.40", trigger_source="last")) + await engine.on_trade({"market_id": "kalshi:KX-A", "price": "0.39", "quantity": "1", "timestamp": engine.started_ts - 1000}) + assert engine.managed_order_controller.get(last.id).state == "waiting" and paper.fills == [] + + +async def test_risk_refusal_is_durable_on_retry(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + from synpath.trading.errors import RiskRejected + try: + await engine.set_risk(RiskConfig(max_order_contracts=D("5"))) + for _ in range(2): + with pytest.raises(RiskRejected) as error: + await engine.submit(stop_request()) + assert error.value.rule == "max_order_contracts" + assert venue.sent == [] + finally: + await engine.stop() + + +async def test_submission_reservation_and_audit_rollback_before_any_dispatch(tmp_path): + engine, venue = await scripted_engine(tmp_path / "engine.db") + with sqlite3.connect(engine.config.journal_path) as connection: + connection.executescript(""" + CREATE TRIGGER fail_reservation_audit BEFORE INSERT ON events + WHEN NEW.kind = 'submission.reserved' + BEGIN SELECT RAISE(ABORT, 'simulated audit failure'); END; + """) + try: + with pytest.raises(sqlite3.IntegrityError, match="simulated audit failure"): + await engine.submit(stop_request()) + assert await engine.journal.submission('protect-a') is None + assert engine.managed_order_controller.parents == {} and venue.sent == [] + finally: + await engine.stop() + + +@pytest.mark.parametrize('action', ['dispatch', 'cancel', 'expire']) +async def test_slow_venue_does_not_block_another_markets_stop_observation(tmp_path, action): + engine, venue = await scripted_engine(tmp_path / 'engine.db') + clock = [1_800_000_000.0] + engine.clock = lambda: clock[0] + first = await engine.submit(stop_request(client_order_id='slow-a')) + fields = dict(market_id='kalshi:KX-B', client_order_id='other-b') + if action == 'expire': + fields.update(type='stop_limit', price='0.38', time_in_force='gtd', expires_at=int((clock[0]+1)*1000)) + second = await engine.submit(stop_request(**fields)) + engine.set_book('kalshi:KX-B', SimpleNamespace(ready=True, best_bid=D('0.39'), best_ask=D('0.41'))) + entered = asyncio.Event() + release = asyncio.Event() + original = venue.create_order + async def slow(request): + if request.tags['parent'] == first.id: + entered.set() + await release.wait() + return await original(request) + venue.create_order = slow + dispatcher = asyncio.create_task(engine._dispatch_loop()) + await asyncio.sleep(0) + try: + await asyncio.wait_for(cross(engine), 1) + await asyncio.wait_for(entered.wait(), 1) + await asyncio.wait_for(engine.on_book({'market_id': 'kalshi:KX-B'}), 1) + managed = engine.managed_order_controller.get(second.id) + assert managed.state == 'working' and managed.pending_child is not None + if action == 'cancel': + await engine.cancel(second.id) + elif action == 'expire': + clock[0] += 2 + release.set() + for _ in range(100): + expected = 'canceled' if action == 'cancel' else 'expired' if action == 'expire' else 'working' + if engine.managed_order_controller.get(first.id).children and managed.pending_child is None and managed.state == expected: + break + await asyncio.sleep(0.005) + assert managed.pending_child is None + assert managed.state == expected + assert len(venue.sent) == (2 if action == 'dispatch' else 1) + finally: + release.set() + dispatcher.cancel() + await asyncio.gather(dispatcher, return_exceptions=True) + await engine.stop() + + +async def test_sell_stop_market_notional_limit_counts_the_no_price(tmp_path): + engine, venue = await scripted_engine(tmp_path / 'engine.db') + parent = await engine.submit(stop_request()) + try: + await engine.set_risk(RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None, + max_order_notional=D('5'))) + await cross(engine) + assert engine.managed_order_controller.get(parent.id).state == 'rejected' + assert '9.90' in engine.managed_order_controller.get(parent.id).detail + assert venue.sent == [], 'buying NO at a 0.01 YES limit can cost 0.99 per contract' + finally: + await engine.stop() + + +async def test_unexpected_decoder_error_after_send_preserves_uncertain_stop(tmp_path): + path = tmp_path / "orders.db" + engine, venue = await scripted_engine(path) + parent = await engine.submit(stop_request()) + original = venue.create_order + async def accepted_then_decoder_failed(request): + await original(request) + raise RuntimeError("response decoder failed after acceptance") + venue.create_order = accepted_then_decoder_failed + try: + await cross(engine) + managed = engine.managed_order_controller.get(parent.id) + assert managed.state == "working" and managed.pending_child is not None + assert "unresolved" in managed.detail and len(venue.sent) == 1 + finally: + await engine.stop() + venue.create_order = original + engine, _ = await scripted_engine(path, venue) + try: + managed = engine.managed_order_controller.get(parent.id) + assert len(managed.children) == 1 and managed.pending_child is None + assert len(venue.sent) == 1 + finally: + await engine.stop() + + +async def test_canceled_commit_keeps_triggered_stop_recoverable(tmp_path, monkeypatch): + path = tmp_path / "orders.db" + engine, venue = await scripted_engine(path) + parent = await engine.submit(stop_request()) + connection = engine.journal._db.connection + entered, release = asyncio.Event(), asyncio.Event() + original = connection.exec_driver_sql + armed = True + async def pause_commit(query, *args, **kwargs): + nonlocal armed + if query == "COMMIT" and armed: + armed = False + entered.set() + await release.wait() + return await original(query, *args, **kwargs) + class DelayedConnection: + def __getattr__(self, name): + return getattr(connection, name) + exec_driver_sql = staticmethod(pause_commit) + monkeypatch.setattr(engine.journal._db, "connection", DelayedConnection()) + triggering = asyncio.create_task(cross(engine)) + try: + await asyncio.wait_for(entered.wait(), 5) + triggering.cancel() + await asyncio.sleep(0) + release.set() + with pytest.raises(asyncio.CancelledError): + await triggering + snapshot = (await engine.journal.managed_one(parent.id))[1] + assert snapshot["state"] == "working" + assert snapshot["extra"]["pending_child"] is not None + assert venue.sent == [] + finally: + release.set() + await engine.stop() + restarted, _ = await scripted_engine(path, venue) + try: + await restarted.managed_order_controller.on_timer() + assert restarted.managed_order_controller.get(parent.id).state != "rejected" + assert len(venue.sent) == 1 + finally: + await restarted.stop() diff --git a/tests/test_sx_bet_parlays.py b/tests/test_sx_bet_parlays.py new file mode 100644 index 0000000..7b39ef3 --- /dev/null +++ b/tests/test_sx_bet_parlays.py @@ -0,0 +1,573 @@ +"""SX Bet parlays as incoming RFQs (P8 of docs/rfq-combo.md), offline. + +The request follows the venue's documented `parlay_markets:global` example +(tests/samples/sx_bet_parlay_request.json); the stream runs over a fake +socket speaking Centrifugo's JSON protocol; REST is mocked with respx. +Covered: the request as an `Rfq` with a `Combo`, the stream feeding the +adapter, a quote as signed orders that expire with the parlay, the re-sign +to the venue's named end, cancel and replace, reading a quote back from its +orders, the void-leg readings, error mapping, and the quoter flow through +`engine.rfq`. +""" +from __future__ import annotations + +import asyncio +import json +import re +import time +from decimal import Decimal as D + +import httpx +import pytest +import respx + +from synpath.errors import NotSupported +from synpath.trading.credentials import SxBetCredentials +from synpath.trading.errors import InvalidOrder, OrderRejected, RfqNotFound +from synpath.trading.sx_bet import ( + PARLAY_EXPIRY_MARGIN_S, SxBetTrading, order_ids_of, order_of_row, parlay_result, quote_id_of, quote_of_orders, + rfq_of_parlay, +) +from synpath.trading.types import ComboLeg, MakerQuote, OrderStatus, QuoteStatus, RfqCloseReason, RfqStatus, Side +from synpath.ws.base import VenueEvent +from synpath.ws.sx_bet import PARLAY_CHANNEL, SxBetParlayStream + +from conftest import load + +API = "https://api.sx.bet" +KEY = "0x" + "11" * 32 +REQUEST = load("sx_bet_parlay_request.json") +PARENT = REQUEST["marketHash"] +COMBO_ID = f"sx_bet:{PARENT}" +LEG_ONE = f"sx_bet:{REQUEST['legs'][0]['marketHash']}" +LEG_TWO = f"sx_bet:{REQUEST['legs'][1]['marketHash']}" +META = { + "chainId": 4162, + "domain": {"name": "OBv3 Escrow", "version": "1", "chainId": 4162, + "verifyingContract": "0x890482680C3a0116aBB003B17e7D694D13a6c1eB"}, + "activeAsset": {"symbol": "USDC", "baseToken": "0x6629Ce1Cf35Cc1329ebB4F63202F3f197b3F050B", "decimals": 6}, + "oddsLadderStepSize": 125, + "limits": {"orderSizeMinimumBaseUnits": "1000000", "maxCreateOrders": 10, "maxCancelOrders": 100}, +} +BID_ID, ASK_ID = "0x" + "b1" * 32, "0x" + "a1" * 32 + + +def run(coro): + return asyncio.run(coro) + + +def adapter(**kw) -> SxBetTrading: + return SxBetTrading(SxBetCredentials(private_key=KEY, api_key="api-key"), catalog=_Catalog(), **kw) + + +class _Catalog: + def close(self): + pass + + +def submitted(req: httpx.Request) -> httpx.Response: + """Every order placed and resting; the bid and the ask get fixed ids.""" + orders = json.loads(req.content)["orders"] + return httpx.Response(200, json={"status": "success", "data": {"orders": [ + {"orderId": BID_ID if o["isMakerBettingOutcomeOne"] else ASK_ID, "status": "SUBMITTED", "commandId": "c", + "outcome": {"state": "RESTED", "remainingAmount": o["totalBetSize"]}} for o in orders]}}) + + +def order_row(order_id: str, *, outcome_one: bool, status: str = "ACTIVE", reason: str | None = None, + remaining: str = "10000000", client: str | None = None) -> dict: + return { + "id": order_id, "marketHash": PARENT, "isBettingOutcomeOne": outcome_one, + "percentageOdds": str(20 * 10 ** 18) if outcome_one else str(70 * 10 ** 18), "totalBetSize": "10000000", + "remainingSize": remaining, "expiry": "2026-10-21T12:01:00.000Z", "status": status, "inactiveReason": reason, + "clientOrderId": client, "createdAt": "2026-10-21T12:00:01.000Z", "updatedAt": "2026-10-21T12:00:01.000Z", + } + + +def mocked(mock: respx.MockRouter, *, post=submitted) -> dict: + mock.get("/metadata/obv3").respond(json={"status": "success", "data": META}) + return {"post": mock.post("/orders-v3").mock(side_effect=post)} + + +def quote(**kw) -> MakerQuote: + return MakerQuote(**{"rfq_id": PARENT, "bid": D("0.20"), "ask": D("0.30"), **kw}) + + +# -- the request ---------------------------------------------------------------------- + + +class TestRequest: + def test_a_request_is_an_incoming_two_sided_rfq_on_a_combo(self): + rfq = rfq_of_parlay(REQUEST, received_at=1_000_000) + assert (rfq.id, rfq.venue, rfq.combo_id, rfq.market_id) == (PARENT, "sx_bet", COMBO_ID, None) + assert rfq.mine is False and rfq.status == RfqStatus.OPEN + assert (rfq.outcome, rfq.action, rfq.quantity) == (None, None, None), "the bettor's side is not said" + assert rfq.amount == D("50"), "requestSize is the stake asked for, in USDC base units" + assert rfq.expires_at == 1_060_000 and rfq.info["expiry_exact"] is False + assert rfq.info["requestorId"] == REQUEST["requestorId"] and "legs" not in rfq.info + combo = rfq.combo + assert combo.market_id == COMBO_ID and combo.venue == "sx_bet" and combo.status == "open" + assert [(l.market_id, l.outcome) for l in combo.legs] == [(LEG_ONE, "no"), (LEG_TWO, "yes")] + assert "VOID" in combo.info["void_leg_rule"] + + def test_a_request_without_a_size_still_maps(self): + rfq = rfq_of_parlay({k: v for k, v in REQUEST.items() if k != "requestSize"}, received_at=0) + assert rfq.amount is None and rfq.quantity is None + + def test_a_request_that_is_not_a_combo_is_dropped(self): + sx = adapter() + assert sx.on_parlay({**REQUEST, "legs": REQUEST["legs"][:1]}) is None + assert sx.on_parlay({**REQUEST, "legs": [REQUEST["legs"][0]] * 2}) is None + assert sx.parlays == {} + + +# -- the stream --------------------------------------------------------------------------- + + +class FakeSocket: + """A Centrifugo server: records what is sent, hands back what a test pushes.""" + + def __init__(self): + self.sent: list = [] + self.inbox: asyncio.Queue = asyncio.Queue() + + async def send(self, text): + self.sent.append(json.loads(text)) + + async def recv(self): + return await self.inbox.get() + + async def close(self): + pass + + +def fake_stream() -> tuple[SxBetParlayStream, FakeSocket]: + socket = FakeSocket() + + async def connect(url, **kw): + return socket + + return SxBetParlayStream(token="tok", websocket_connection_opener=connect), socket + + +def push(data) -> str: + return json.dumps({"push": {"channel": PARLAY_CHANNEL, "pub": {"data": data}}}) + + +async def settle(until=lambda: True, steps: int = 50): + for _ in range(steps): + if until(): + return + await asyncio.sleep(0.005) + + +async def handshake(socket: FakeSocket): + """Answer the connect, then the subscription, as the server does.""" + await settle(lambda: any("connect" in c for c in socket.sent)) + connect = next(c for c in socket.sent if "connect" in c) + assert connect["connect"]["token"] == "tok" + await socket.inbox.put(json.dumps({"id": connect["id"], "connect": {"client": "c", "pong": True}})) + await settle(lambda: any("subscribe" in c for c in socket.sent)) + subscribe = next(c for c in socket.sent if "subscribe" in c) + assert subscribe["subscribe"]["channel"] == PARLAY_CHANNEL + await socket.inbox.put(json.dumps({"id": subscribe["id"], "subscribe": {}})) + + +class TestStream: + def test_the_channel_emits_the_request_as_an_rfq(self): + stream, _ = fake_stream() + [event] = stream.on_publication(PARLAY_CHANNEL, REQUEST) + assert isinstance(event, VenueEvent) and event.name == "parlay_request" + assert event.payload["row"] == REQUEST and event.payload["rfq"].combo_id == COMBO_ID + assert stream.on_publication("markets:global", REQUEST) == [] + + def test_requests_reach_the_adapter_and_its_listeners(self): + heard = [] + + async def go(): + stream, socket = fake_stream() + async with adapter(parlay_stream=stream) as sx: + sx.subscribe_incoming_rfqs(heard.append) + assert list(await sx.fetch_incoming_rfqs()) == [], "the first call starts the stream" + await handshake(socket) + await socket.inbox.put(push(REQUEST)) + await socket.inbox.put(push(REQUEST)) # the same request again + await settle(lambda: PARENT in sx.parlays) + incoming = await sx.fetch_incoming_rfqs() + by_leg = await sx.fetch_incoming_rfqs(market_id=LEG_TWO) + elsewhere = await sx.fetch_incoming_rfqs(market_id="sx_bet:0x" + "00" * 32) + combo = await sx.fetch_combo(COMBO_ID) + found = await sx.lookup_combo([ComboLeg(market_id=LEG_TWO, outcome="yes"), + ComboLeg(market_id=LEG_ONE, outcome="no")]) + missing = await sx.lookup_combo([ComboLeg(market_id=LEG_TWO, outcome="no"), + ComboLeg(market_id=LEG_ONE, outcome="no")]) + await stream.close() + return incoming, by_leg, elsewhere, combo, found, missing + + incoming, by_leg, elsewhere, combo, found, missing = run(go()) + assert [r.id for r in incoming] == [PARENT] and [r.id for r in by_leg] == [PARENT] and list(elsewhere) == [] + assert len(heard) == 1 and heard[0].id == PARENT + assert combo.market_id == COMBO_ID and found == combo and missing is None + + def test_an_async_listener_is_scheduled(self): + heard = [] + + async def listener(rfq): + heard.append(rfq.id) + + async def go(): + sx = adapter() + sx.subscribe_incoming_rfqs(listener) + sx.on_parlay(REQUEST) + await settle(lambda: heard) + + run(go()) + assert heard == [PARENT] + + +# -- quoting -------------------------------------------------------------------------------- + + +class TestQuote: + def test_a_quote_is_a_bid_and_an_ask_order_expiring_with_the_parlay(self): + now = 1_800_000_000.0 + + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + routes = mocked(mock) + sx = adapter() + sx.clock = lambda: now + sx.on_parlay(REQUEST) + result = await sx.send_quote(quote(client_order_id="q-1")) + body = json.loads(routes["post"].calls[0].request.content) + await sx.close() + return result, body + + result, body = run(go()) + bid, ask = body["orders"] + expiry = int(now) + 60 - PARLAY_EXPIRY_MARGIN_S + assert body["waitForOutcome"] is True + assert (bid["isMakerBettingOutcomeOne"], bid["percentageOdds"]) == (True, str(20 * 10 ** 18)) + assert (ask["isMakerBettingOutcomeOne"], ask["percentageOdds"]) == (False, str(70 * 10 ** 18)) + assert bid["marketHash"] == PARENT and ask["marketHash"] == PARENT + assert bid["timeInForce"] == ask["timeInForce"] == "GTC" + assert bid["expiry"] == ask["expiry"] == expiry + assert (bid["clientOrderId"], ask["clientOrderId"]) == ("q-1:bid", "q-1:ask") + # No quantity: each side covers the 50 USDC asked for at the bettor's price. + assert bid["totalBetSize"] == str(int(D("62.5") * D("0.2") * 10 ** 6)) # bettor pays 0.8 a contract + assert ask["totalBetSize"] == str(int(D("166.666666") * D("0.7") * 10 ** 6)) # bettor pays 0.3 + assert result.id == quote_id_of(BID_ID, ASK_ID) and order_ids_of(result.id) == (BID_ID, ASK_ID) + assert (result.bid, result.ask, result.status, result.mine) == (D("0.2"), D("0.3"), QuoteStatus.OPEN, True) + assert (result.rfq_id, result.market_id, result.expires_at) == (PARENT, COMBO_ID, expiry * 1000) + assert result.info["time_in_force"] == "GTC" and result.info["refused"] == {} + + def test_a_one_sided_quote_and_its_own_expiry(self): + now = 1_800_000_000.0 + + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + routes = mocked(mock) + sx = adapter() + sx.clock = lambda: now + sx.on_parlay(REQUEST) + result = await sx.send_quote(quote(bid=None, quantity=D("10"), post_only=True, + expires_at=int(now + 20) * 1000)) + return result, json.loads(routes["post"].calls[0].request.content) + + result, body = run(go()) + [ask] = body["orders"] + assert ask["expiry"] == int(now) + 20 and ask["postOnly"] is True and ask["totalBetSize"] == "7000000" + assert result.id == f"~{ASK_ID}" and result.bid is None and result.ask_quantity == D("10") + + def test_an_order_past_the_parlay_is_resigned_to_the_end_the_venue_names(self): + now = 1_800_000_000.0 + exact = int(now) + 41 + answers = iter([ + httpx.Response(400, json={"message": "PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY", "marketHash": PARENT, + "parlayExpiry": exact}), + ]) + + def post(req): + return next(answers, None) or submitted(req) + + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + routes = mocked(mock, post=post) + sx = adapter() + sx.clock = lambda: now + sx.on_parlay(REQUEST) + result = await sx.send_quote(quote()) + expiries = [json.loads(c.request.content)["orders"][0]["expiry"] for c in routes["post"].calls] + return result, expiries, sx.parlays[PARENT] + + result, expiries, rfq = run(go()) + assert expiries == [int(now) + 60 - PARLAY_EXPIRY_MARGIN_S, exact] + assert rfq.expires_at == exact * 1000 and rfq.info["expiry_exact"] is True + assert result.expires_at == exact * 1000 + + def test_an_expired_parlay_is_refused_before_anything_is_sent(self): + clock = [1_800_000_000.0] + + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + routes = mocked(mock) + sx = adapter(parlay_stream=fake_stream()[0]) + sx.clock = lambda: clock[0] + sx.on_parlay(REQUEST) + clock[0] += 61 + with pytest.raises(OrderRejected) as refused: + await sx.send_quote(quote()) + incoming = await sx.fetch_incoming_rfqs() + closed = await sx.fetch_rfq(PARENT) + await sx.close() + return refused.value, incoming, closed, routes["post"].call_count + + refused, incoming, closed, posts = run(go()) + assert refused.reason == "parlay_expired" and posts == 0 + assert list(incoming) == [] + assert (closed.status, closed.closed_reason) == (RfqStatus.CLOSED, RfqCloseReason.EXPIRED) + + def test_a_quote_on_a_request_never_heard_is_not_found(self): + async def go(): + with pytest.raises(RfqNotFound, match="parlay_markets:global"): + await adapter().send_quote(quote()) + + run(go()) + + @pytest.mark.parametrize("body,error,reason", [ + ({"message": "INVALID_MARKET", "marketHash": PARENT}, OrderRejected, "parlay_expired"), + ({"message": "PARLAY_LEG_INACTIVE", "parentMarketHash": PARENT, "legMarketHash": "0x22", + "legStatus": "INACTIVE"}, OrderRejected, "parlay_leg_inactive"), + ({"message": "TOTAL_BET_SIZE_TOO_LOW", "marketHash": PARENT, "minimum": "1"}, InvalidOrder, None), + ({"message": "PARLAY_ORDER_EXPIRY_BEYOND_PARLAY_EXPIRY"}, InvalidOrder, None), + ]) + def test_refusals_are_typed(self, body, error, reason): + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + mocked(mock, post=lambda req: httpx.Response(400, json=body)) + sx = adapter() + sx.on_parlay(REQUEST) + with pytest.raises(error) as raised: + await sx.send_quote(quote()) + return raised.value + + raised = run(go()) + if reason: + assert raised.reason == reason + + def test_one_side_refused_leaves_a_one_sided_quote(self): + def post(req): + return httpx.Response(200, json={"status": "success", "data": {"orders": [ + {"orderId": BID_ID, "status": "SUBMITTED", "outcome": {"state": "RESTED"}}, + {"orderId": ASK_ID, "status": "FAILED"}]}}) + + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + mocked(mock, post=post) + sx = adapter() + sx.on_parlay(REQUEST) + return await sx.send_quote(quote()) + + result = run(go()) + assert result.id == f"{BID_ID}~" and result.ask is None and "ask" in result.info["refused"] + + def test_there_is_no_last_look_and_no_combo_create(self): + async def go(): + sx = adapter() + sx.on_parlay(REQUEST) + with pytest.raises(NotSupported, match="no last look"): + await sx.confirm_quote(None) + with pytest.raises(NotSupported, match="bettor's request"): + await sx.create_combo([ComboLeg(market_id=LEG_ONE, outcome="yes"), + ComboLeg(market_id=LEG_TWO, outcome="yes")]) + with pytest.raises(NotSupported): + await sx.create_rfq(COMBO_ID, amount=D("5")) + + run(go()) + + +# -- cancel, replace, read back ------------------------------------------------------------- + + +class TestLifecycle: + def test_cancel_cancels_both_orders_and_replace_sends_anew(self): + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + routes = mocked(mock) + delete = mock.delete("/orders-v3").respond(json={"status": "success", "data": {}}) + mock.get(f"/orders-v3/{BID_ID}").respond(json={"status": "success", "data": order_row( + BID_ID, outcome_one=True, status="INACTIVE", reason="CANCELLED")}) + mock.get(f"/orders-v3/{ASK_ID}").respond(json={"status": "success", "data": order_row( + ASK_ID, outcome_one=False, status="INACTIVE", reason="CANCELLED")}) + sx = adapter() + sx.on_parlay(REQUEST) + first = await sx.send_quote(quote()) + await sx.cancel_quote(first.id) + cancelled = json.loads(delete.calls[0].request.content) + again = await sx.replace_quote(first.id, quote(bid=D("0.21"))) + return cancelled, again, routes["post"].call_count, delete.call_count + + cancelled, again, posts, deletes = run(go()) + assert cancelled == {"orders": [{"orderId": BID_ID}, {"orderId": ASK_ID}]} + assert posts == 2 and deletes == 2 and again.bid == D("0.21") + + def test_cancel_of_a_quote_the_venue_does_not_know(self): + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + mocked(mock) + mock.delete("/orders-v3").respond(json={"status": "success", "data": {}}) + mock.get(url__regex=r"/orders-v3/0x.*").respond(404, json={"message": "not found"}) + with pytest.raises(RfqNotFound): + await adapter().cancel_quote(quote_id_of(BID_ID, ASK_ID)) + + run(go()) + + @pytest.mark.parametrize("bid,ask,status", [ + (dict(status="ACTIVE"), dict(status="ACTIVE"), QuoteStatus.OPEN), + (dict(status="ACTIVE", remaining="5000000"), dict(status="INACTIVE", reason="EXPIRED"), QuoteStatus.OPEN), + (dict(status="INACTIVE", reason="FILLED", remaining="0"), dict(status="INACTIVE", reason="EXPIRED"), + QuoteStatus.EXECUTED), + (dict(status="INACTIVE", reason="EXPIRED"), dict(status="INACTIVE", reason="EXPIRED"), QuoteStatus.EXPIRED), + (dict(status="INACTIVE", reason="CANCELLED"), dict(status="INACTIVE", reason="EXPIRED"), QuoteStatus.CANCELED), + ]) + def test_a_quote_reads_back_from_its_orders(self, bid, ask, status): + b = order_of_row(order_row(BID_ID, outcome_one=True, **bid)) + a = order_of_row(order_row(ASK_ID, outcome_one=False, **ask)) + result = quote_of_orders(b, a) + assert result.status == status and result.rfq_id == PARENT and result.market_id == COMBO_ID + assert (result.bid, result.ask) == (D("0.2"), D("0.3")) + if status == QuoteStatus.EXECUTED: + assert result.maker_order_id == BID_ID + + def test_fetch_quote_and_my_quotes_find_orders_sent_in_doubt(self): + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + mocked(mock) + mock.get(f"/orders-v3/{BID_ID}").respond(json={"status": "success", "data": order_row( + BID_ID, outcome_one=True, status="INACTIVE", reason="FILLED", remaining="0")}) + mock.get(f"/orders-v3/{ASK_ID}").respond(404, json={"message": "not found"}) + mock.get("/orders-v3").respond(json={"status": "success", "data": {"orders": [ + order_row("0x" + "c1" * 32, outcome_one=True, client="rfq-quote:sx_bet:k:bid"), + order_row("0x" + "c2" * 32, outcome_one=False, client="rfq-quote:sx_bet:k:ask"), + ]}}) + sx = adapter() + one = await sx.fetch_quote(quote_id_of(BID_ID, ASK_ID)) + mine = await sx.fetch_my_quotes(rfq_id=PARENT, status=QuoteStatus.OPEN) + return one, mine + + one, [found] = run(go()) + assert one.id == quote_id_of(BID_ID, ASK_ID) and one.status == QuoteStatus.EXECUTED and one.ask is None + assert found.id == quote_id_of("0x" + "c1" * 32, "0x" + "c2" * 32) and found.rfq_id == PARENT + + +# -- void legs ------------------------------------------------------------------------------ + + +class TestVoidLegs: + @pytest.mark.parametrize("legs,one,two", [ + # (developer docs, user guide, worst) for the parlay WIN side, then the LOSE side. + (["won", "won"], ("won", "won", "won"), ("lost", "lost", "lost")), + (["won", "lost"], ("lost", "lost", "lost"), ("won", "won", "won")), + (["won", "open"], ("open", "open", "open"), ("open", "open", "open")), + (["won", "void"], ("refund", "lost", "lost"), ("refund", "lost", "lost")), + (["lost", "void"], ("refund", "lost", "lost"), ("refund", "lost", "lost")), + (["open", "void"], ("refund", "lost", "lost"), ("refund", "lost", "lost")), + ]) + def test_both_readings_and_the_worse_one(self, legs, one, two): + for outcome_one, expected in ((True, one), (False, two)): + result = parlay_result(legs, outcome_one=outcome_one) + assert (result["developer_docs"], result["user_guide"], result["worst"]) == expected, (legs, outcome_one) + + def test_unknown_results_are_refused(self): + with pytest.raises(ValueError): + parlay_result(["won", "push?"]) + + +# -- through the engine --------------------------------------------------------------------- + + +class TestEngine: + def test_the_quoter_flow_through_engine_rfq(self, tmp_path): + from synpath.engine import Engine, EngineConfig, RiskConfig + + filled = {"done": False} + + def order(order_id, outcome_one): + def answer(req): + if filled["done"]: + reason = "FILLED" if outcome_one else "EXPIRED" + return httpx.Response(200, json={"status": "success", "data": order_row( + order_id, outcome_one=outcome_one, status="INACTIVE", reason=reason, + remaining="0" if outcome_one else "10000000")}) + return httpx.Response(200, json={"status": "success", "data": order_row(order_id, outcome_one=outcome_one)}) + return answer + + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + mocked(mock) + mock.get(f"/orders-v3/{BID_ID}").mock(side_effect=order(BID_ID, True)) + mock.get(f"/orders-v3/{ASK_ID}").mock(side_effect=order(ASK_ID, False)) + stream, socket = fake_stream() + sx = adapter(parlay_stream=stream) + engine = Engine({"sx_bet": sx}, EngineConfig( + journal_path=str(tmp_path / "engine.db"), in_doubt_timeout_s=0, rfq_poll_interval_s=0.01, + rfq_incoming_interval_s=0, + ), risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None)) + await engine.start() + assert engine.rfq.pushed == {"sx_bet"}, "subscribed to the stream, not polled" + sub = engine.bus.subscribe("rfq", "quote") + heard = [] + engine.rfq.on_incoming_rfq(lambda rfq: heard.append(rfq.id)) + await engine.rfq.tick() # starts the parlay stream + await handshake(socket) + await socket.inbox.put(push(REQUEST)) + await settle(lambda: heard == [PARENT]) # pushed: no tick in between + assert heard == [PARENT] + assert f"sx_bet:{PARENT}" in engine.rfq.incoming + sent = await engine.send_quote(MakerQuote(rfq_id=PARENT, bid=D("0.20"), ask=D("0.30"), book="mm")) + held = dict(engine.rfq.maker_quotes) + with pytest.raises(OrderRejected, match="only an accepted quote"): + await engine.confirm_quote(sent.id) # never accepted: no last look on SX + filled["done"] = True # the bettor took the bid + await engine.rfq.tick() + out = [] + while not sub.queue.empty(): + out.append(sub.queue.get_nowait().kind) + left = dict(engine.rfq.maker_quotes) + await engine.stop() + await sx.close() + return sent, held, out, left + + sent, held, kinds, left = run(go()) + assert sent.id == quote_id_of(BID_ID, ASK_ID) and sent.mine + assert held[f"sx_bet:{sent.id}"]["market_id"] == COMBO_ID + assert kinds[:2] == ["rfq.incoming", "quote.sent"] and "quote.executed" in kinds + assert left == {} + + def test_halt_cancels_a_live_parlay_quote(self, tmp_path): + from synpath.engine import Engine, EngineConfig, RiskConfig + + async def go(): + with respx.mock(base_url=API, assert_all_called=False) as mock: + mocked(mock) + delete = mock.delete("/orders-v3").respond(json={"status": "success", "data": {}}) + mock.delete("/orders-v3/all").respond(json={"status": "success", "data": {}}) + mock.get("/orders-v3").respond(json={"status": "success", "data": {"orders": []}}) + for order_id, one in ((BID_ID, True), (ASK_ID, False)): + mock.get(f"/orders-v3/{order_id}").respond(json={"status": "success", "data": order_row( + order_id, outcome_one=one, status="INACTIVE", reason="CANCELLED")}) + sx = adapter(parlay_stream=fake_stream()[0]) + sx.on_parlay(REQUEST) + engine = Engine({"sx_bet": sx}, EngineConfig( + journal_path=str(tmp_path / "engine.db"), in_doubt_timeout_s=0, rfq_incoming_interval_s=None, + ), risk=RiskConfig(price_collar=None, duplicate_window_ms=0, closing_soon_s=None)) + await engine.start() + sent = await engine.send_quote(MakerQuote(rfq_id=PARENT, bid=D("0.20"), ask=D("0.30"))) + result = await engine.halt("operator") + left = dict(engine.rfq.maker_quotes) + await engine.stop() + await sx.close() + return sent, result, left, [json.loads(c.request.content) for c in delete.calls] + + sent, result, left, deletes = run(go()) + assert result["rfq"]["quotes"]["sx_bet"] == 1 and left == {} + assert {"orders": [{"orderId": BID_ID}, {"orderId": ASK_ID}]} in deletes diff --git a/tests/test_sx_bet_trading.py b/tests/test_sx_bet_trading.py index f63135a..3b791e0 100644 --- a/tests/test_sx_bet_trading.py +++ b/tests/test_sx_bet_trading.py @@ -141,7 +141,6 @@ def test_post_only_rests_only(self): @pytest.mark.parametrize("changes", [ {"type": OrderType.ICEBERG}, - {"reduce_only": True}, {"time_in_force": TimeInForce.DAY}, {"time_in_force": TimeInForce.GTD, "expires_at": 1_000}, ]) @@ -195,7 +194,8 @@ def test_a_fill(self): assert (fill.side, fill.price, fill.quantity, fill.liquidity) == (Side.SELL, Decimal("0.6"), Decimal("0.833335"), Liquidity.TAKER) assert fill.settlement == SettlementState.CONFIRMED assert fill_of_row({**FILL_ROW, "status": "MATCHED"}).settlement == SettlementState.MATCHED - assert fill_of_row({**FILL_ROW, "isParlay": True}) is None + parlay = fill_of_row({**FILL_ROW, "isParlay": True}) + assert parlay.market_id == MARKET_ID, "a parlay fill books on the parlay market (the parent hash)" def test_a_position_nets_on_yes(self): position = position_of(POSITION_ROW) diff --git a/tests/test_sx_bet_ws.py b/tests/test_sx_bet_ws.py index ac42918..0fe11c4 100644 --- a/tests/test_sx_bet_ws.py +++ b/tests/test_sx_bet_ws.py @@ -71,14 +71,14 @@ async def connected(stream): await stream.on_connect() connect = stream._ws.sent[-1] assert connect["connect"]["token"] == "tok" - stream._dispatch(json.dumps({"id": connect["id"], "connect": {"client": "c", "ping": 25, "pong": True}})) + stream._process_raw_frame_and_emit_events(json.dumps({"id": connect["id"], "connect": {"client": "c", "ping": 25, "pong": True}})) await settle() return stream def confirm_subscriptions(stream): for command in [c for c in stream._ws.sent if "subscribe" in c]: - stream._dispatch(json.dumps({"id": command["id"], "subscribe": {}})) + stream._process_raw_frame_and_emit_events(json.dumps({"id": command["id"], "subscribe": {}})) class TestRecords: @@ -127,7 +127,7 @@ def test_pings_are_answered(self): async def go(): stream = await connected(stream_with()) before = len(stream._ws.sent) - stream._dispatch("{}") + stream._process_raw_frame_and_emit_events("{}") await settle() return stream._ws.sent[before:] @@ -138,7 +138,7 @@ async def go(): stream = await connected(stream_with()) await stream.watch_ticker([MARKET]) body = {**BOOK, "version": "9" * 30} - stream._dispatch(push(f"orderbook_v3:{HASH}", body) + "\n" + "{}") + stream._process_raw_frame_and_emit_events(push(f"orderbook_v3:{HASH}", body) + "\n" + "{}") await settle() return stream._ws.sent, drain(stream) @@ -150,8 +150,8 @@ async def go(): stream = await connected(stream_with()) await stream.watch_order_book([MARKET]) drain(stream) - stream._dispatch(push(f"orderbook_v3:{HASH}", {**BOOK, "version": "2"})) - stream._dispatch(push(f"orderbook_v3:{HASH}", {**BOOK, "version": "1", "outcomeOne": []})) + stream._process_raw_frame_and_emit_events(push(f"orderbook_v3:{HASH}", {**BOOK, "version": "2"})) + stream._process_raw_frame_and_emit_events(push(f"orderbook_v3:{HASH}", {**BOOK, "version": "1", "outcomeOne": []})) return drain(stream) books = [e for e in asyncio.run(go()) if isinstance(e, BookEvent)] @@ -162,7 +162,7 @@ async def go(): stream = await connected(stream_with()) await stream.watch_trades() command = [c for c in stream._ws.sent if "subscribe" in c][-1] - stream._dispatch(json.dumps({"id": command["id"], "error": {"code": 103, "message": "permission denied"}})) + stream._process_raw_frame_and_emit_events(json.dumps({"id": command["id"], "error": {"code": 103, "message": "permission denied"}})) return stream, drain(stream) stream, events = asyncio.run(go()) @@ -175,7 +175,7 @@ async def go(): stream._ws = FakeSocket() await stream.on_connect() connect = stream._ws.sent[-1] - stream._dispatch(json.dumps({"id": connect["id"], "connect": {"expires": True, "ttl": 1}})) + stream._process_raw_frame_and_emit_events(json.dumps({"id": connect["id"], "connect": {"expires": True, "ttl": 1}})) await asyncio.sleep(5.2) sent = stream._ws.sent stream.on_disconnect() @@ -192,8 +192,8 @@ def test_trades_are_filtered_to_the_markets_asked_for(self): async def go(): stream = await connected(stream_with()) await stream.watch_trades([MARKET]) - stream._dispatch(push("recent_trades_v3:global", {"trade": other})) - stream._dispatch(push("recent_trades_v3:global", {"trade": mine})) + stream._process_raw_frame_and_emit_events(push("recent_trades_v3:global", {"trade": other})) + stream._process_raw_frame_and_emit_events(push("recent_trades_v3:global", {"trade": mine})) return drain(stream) trades = [e for e in asyncio.run(go()) if isinstance(e, TradeEvent)] @@ -205,7 +205,7 @@ def test_every_market_change_when_none_are_named(self): async def go(): stream = await connected(stream_with()) await stream.watch_market_status() - stream._dispatch(push("markets:global", rows)) + stream._process_raw_frame_and_emit_events(push("markets:global", rows)) return drain(stream) states = [(e.market_id, e.state) for e in asyncio.run(go()) if isinstance(e, MarketStatusEvent)] diff --git a/tests/test_trading_foundations.py b/tests/test_trading_foundations.py index b492019..93b446e 100644 --- a/tests/test_trading_foundations.py +++ b/tests/test_trading_foundations.py @@ -32,9 +32,9 @@ class TestCapabilityKeys: def test_every_read_adapter_answers_every_trading_key_with_false(self): trading = [key for key in CAPABILITIES if key in ( "create_order", "cancel_order", "edit_order", "fetch_balance", "fetch_positions", - "fetch_open_orders", "watch_orders", "rfq", "split_merge", + "fetch_open_orders", "watch_orders", "rfq", "rfq_taker", "rfq_quoter", "combos", "split_merge", )] - assert len(trading) == 9 + assert len(trading) == 12 for venue in synpath.exchanges.values(): for key in trading: assert venue.has[key] is False, (venue.id, key) diff --git a/tests/test_ws.py b/tests/test_ws.py index a300533..834e4e9 100644 --- a/tests/test_ws.py +++ b/tests/test_ws.py @@ -27,7 +27,7 @@ from synpath.trading.credentials import KalshiCredentials, PolymarketUSCredentials from synpath.trading.types import Liquidity, OrderStatus, PositionSide, SettlementState, Side from synpath.ws.base import ( - BalanceEvent, BookEvent, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream, + decode_json_or_text, BalanceEvent, BookEvent, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream, StreamStatusEvent, TradeEvent, VenueEvent, ) from synpath.ws.kalshi import KalshiStream @@ -157,7 +157,10 @@ def __init__(self, **kwargs): async def on_connect(self): await self.send({"subscribe": self.subscriptions}) - def handle(self, message): + def handle_raw(self, raw): + return self._handle_decoded_message(decode_json_or_text(raw)) + + def _handle_decoded_message(self, message): if isinstance(message, dict) and "value" in message: return [VenueEvent(venue="test", name="value", payload=message)] if isinstance(message, dict) and "boom" in message: @@ -166,9 +169,50 @@ def handle(self, message): class TestStreamLifecycle: + async def test_connection_events_observe_complete_transitions_and_shutdown_is_quiet(self): + class ObservedEcho(Echo): + def __init__(self, **kwargs): + self.observations = [] + self.cleanup_calls = 0 + super().__init__(**kwargs) + + def on_disconnect(self): + self.cleanup_calls += 1 + + def emit(self, event): + if isinstance(event, StreamStatusEvent): + # Inspect state at publication, rather than after the whole + # receive loop has had time to finish its transition. + self.observations.append(( + event.state, self.connected.is_set(), self.stats.connects, + self.stats.disconnects, self.cleanup_calls, + )) + super().emit(event) + + first = FakeConnection([{"value": 1}], close_after=True) + second = FakeConnection([{"value": 2}]) + stream = ObservedEcho( + websocket_connection_opener=Connector(first, second), backoff_initial=0.001, backoff_max=0.002, + ) + try: + stream.start() + await wait_for(lambda: stream.stats.messages == 2) + assert stream.observations == [ + ("connected", True, 1, 0, 0), + ("disconnected", False, 1, 1, 1), + ("connected", True, 2, 1, 1), + ] + finally: + await stream.close() + # Deliberate close still records and cleans up the connection, without + # telling consumers that an unexpected disconnect/retry occurred. + assert len(stream.observations) == 3 + assert not stream.connected.is_set() + assert stream.stats.disconnects == stream.cleanup_calls == 2 + async def test_subscribes_on_connect_and_delivers_events(self): conn = FakeConnection([{"value": 1}, {"value": 2}]) - stream = Echo(connect=Connector(conn), backoff_initial=0.001) + stream = Echo(websocket_connection_opener=Connector(conn), backoff_initial=0.001) stream.start() await wait_for(lambda: stream.stats.messages == 2) events = drain(stream) @@ -180,7 +224,7 @@ async def test_subscribes_on_connect_and_delivers_events(self): async def test_reconnects_and_resubscribes(self): first = FakeConnection([{"value": 1}], close_after=True) second = FakeConnection([{"value": 2}]) - stream = Echo(connect=Connector(first, second), backoff_initial=0.001, backoff_max=0.002) + stream = Echo(websocket_connection_opener=Connector(first, second), backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.connects == 2 and stream.stats.messages == 2) events = drain(stream) @@ -193,7 +237,7 @@ async def test_reconnects_and_resubscribes(self): async def test_a_private_stream_asks_for_reconciliation_after_a_reconnect(self): class Private(Echo): private = True - stream = Private(connect=Connector(FakeConnection([], close_after=True), FakeConnection([])), backoff_initial=0.001, backoff_max=0.002) + stream = Private(websocket_connection_opener=Connector(FakeConnection([], close_after=True), FakeConnection([])), backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.connects == 2) connected = [e for e in of(drain(stream), StreamStatusEvent) if e.state == "connected"] @@ -201,7 +245,7 @@ class Private(Echo): await stream.close() async def test_a_silent_connection_is_replaced(self): - stream = Echo(connect=Connector(FakeConnection([]), FakeConnection([{"value": 9}])), idle_timeout=0.05, backoff_initial=0.001, backoff_max=0.002) + stream = Echo(websocket_connection_opener=Connector(FakeConnection([]), FakeConnection([{"value": 9}])), idle_timeout=0.05, backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.messages == 1) disconnected = [e for e in of(drain(stream), StreamStatusEvent) if e.state == "disconnected"] @@ -213,7 +257,7 @@ async def test_a_connection_that_died_while_the_machine_slept_is_replaced(self): timeout counts no time while the connection dies. The wall clock still moves, and the stream drops the connection when it wakes.""" first, second = FakeConnection([]), FakeConnection([{"value": 9}]) - stream = Echo(connect=Connector(first, second), idle_timeout=4.0, backoff_initial=0.001, backoff_max=0.002) + stream = Echo(websocket_connection_opener=Connector(first, second), idle_timeout=4.0, backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.connects == 1) stream._last_seen -= 600_000 # ten minutes of sleep @@ -224,7 +268,7 @@ async def test_a_connection_that_died_while_the_machine_slept_is_replaced(self): await stream.close() async def test_failed_connects_back_off_then_succeed(self): - stream = Echo(connect=Connector(OSError("refused"), OSError("refused"), FakeConnection([{"value": 1}])), backoff_initial=0.001, backoff_max=0.002) + stream = Echo(websocket_connection_opener=Connector(OSError("refused"), OSError("refused"), FakeConnection([{"value": 1}])), backoff_initial=0.001, backoff_max=0.002) stream.start() await wait_for(lambda: stream.stats.messages == 1) states = [e.state for e in of(drain(stream), StreamStatusEvent)] @@ -233,7 +277,7 @@ async def test_failed_connects_back_off_then_succeed(self): async def test_an_unreadable_message_does_not_end_the_stream(self): conn = FakeConnection([{"boom": 1}, {"value": 2}, "not json"]) - stream = Echo(connect=Connector(conn)) + stream = Echo(websocket_connection_opener=Connector(conn)) stream.start() await wait_for(lambda: stream.stats.messages == 3) events = drain(stream) @@ -242,7 +286,7 @@ async def test_an_unreadable_message_does_not_end_the_stream(self): await stream.close() async def test_iteration_ends_on_close(self): - stream = Echo(connect=Connector(FakeConnection([{"value": 1}]))) + stream = Echo(websocket_connection_opener=Connector(FakeConnection([{"value": 1}]))) seen = [] async def consume(): @@ -256,14 +300,14 @@ async def consume(): async def test_a_closed_stream_does_not_reconnect_when_iterated(self): connector = Connector(FakeConnection([{"value": 1}]), FakeConnection([{"value": 2}])) - stream = Echo(connect=connector) + stream = Echo(websocket_connection_opener=connector) stream.start() await wait_for(lambda: stream.stats.messages == 1) await stream.close() drained = await asyncio.wait_for(_collect_all(stream), 1) # ends: the close marker is queued assert [e.payload["value"] for e in drained if isinstance(e, VenueEvent)] == [1] await asyncio.sleep(0.01) - assert len(connector.opened) == 1 and stream._task is None + assert len(connector.opened) == 1 and stream._receiver_task is None async def test_a_real_server_dropping_the_client(self): websockets = pytest.importorskip("websockets") @@ -329,7 +373,7 @@ def replay_kalshi(stream: KalshiStream, rows: list[dict], *, skip=lambda row: Fa stream.books.setdefault(ticker, LocalBook()) for row in rows: if row["dir"] == "in" and not skip(row): - stream._dispatch(json.dumps(row["msg"])) + stream._process_raw_frame_and_emit_events(json.dumps(row["msg"])) class TestKalshi: @@ -414,7 +458,7 @@ async def test_subscribing_before_and_after_the_server_names_the_channel(self, k await kalshi.watch_order_book(["A"]) await kalshi.watch_order_book(["B"]) # before the sid is known assert conn.sent == [{"id": 1, "cmd": "subscribe", "params": {"channels": ["orderbook_delta"], "market_tickers": ["A"]}}] - kalshi._dispatch(json.dumps({"type": "subscribed", "id": 1, "msg": {"channel": "orderbook_delta", "sid": 7}})) + kalshi._process_raw_frame_and_emit_events(json.dumps({"type": "subscribed", "id": 1, "msg": {"channel": "orderbook_delta", "sid": 7}})) await asyncio.sleep(0) assert conn.sent[1]["params"] == {"sids": [7], "market_tickers": ["B"], "action": "add_markets"} await kalshi.watch_order_book(["C", "A"]) @@ -436,23 +480,23 @@ async def test_buffer_overflow_restarts_the_subscription(self, kalshi): conn = FakeConnection() kalshi._ws = conn await kalshi.watch_order_book(["A"]) - kalshi._dispatch(json.dumps({"type": "subscribed", "id": 1, "msg": {"channel": "orderbook_delta", "sid": 3}})) - kalshi._dispatch(json.dumps({"type": "orderbook_snapshot", "sid": 3, "seq": 1, "msg": {"market_ticker": "A", "yes_dollars_fp": [["0.4000", "1.00"]]}})) - kalshi._dispatch(json.dumps({"type": "error", "sid": 3, "msg": {"code": 25, "msg": "Subscription buffer overflow"}})) + kalshi._process_raw_frame_and_emit_events(json.dumps({"type": "subscribed", "id": 1, "msg": {"channel": "orderbook_delta", "sid": 3}})) + kalshi._process_raw_frame_and_emit_events(json.dumps({"type": "orderbook_snapshot", "sid": 3, "seq": 1, "msg": {"market_ticker": "A", "yes_dollars_fp": [["0.4000", "1.00"]]}})) + kalshi._process_raw_frame_and_emit_events(json.dumps({"type": "error", "sid": 3, "msg": {"code": 25, "msg": "Subscription buffer overflow"}})) await asyncio.sleep(0) assert [f["cmd"] for f in conn.sent[1:]] == ["unsubscribe", "subscribe"] assert not kalshi.books["A"].ready assert [e.state for e in of(drain(kalshi), StreamStatusEvent)][-1] == "gap" def test_lifecycle(self, kalshi): - events = kalshi.handle({"type": "market_lifecycle_v2", "sid": 1, "seq": 1, "msg": {"market_ticker": "A", "event_type": "deactivated", "is_deactivated": True}}) - events += kalshi.handle({"type": "market_lifecycle_v2", "sid": 1, "seq": 2, "msg": {"market_ticker": "A", "event_type": "activated", "is_deactivated": False}}) - events += kalshi.handle({"type": "market_lifecycle_v2", "sid": 1, "seq": 3, "msg": {"market_ticker": "A", "event_type": "determined", "result": "yes", "determination_ts": 1789660000}}) + events = kalshi._handle_decoded_message({"type": "market_lifecycle_v2", "sid": 1, "seq": 1, "msg": {"market_ticker": "A", "event_type": "deactivated", "is_deactivated": True}}) + events += kalshi._handle_decoded_message({"type": "market_lifecycle_v2", "sid": 1, "seq": 2, "msg": {"market_ticker": "A", "event_type": "activated", "is_deactivated": False}}) + events += kalshi._handle_decoded_message({"type": "market_lifecycle_v2", "sid": 1, "seq": 3, "msg": {"market_ticker": "A", "event_type": "determined", "result": "yes", "determination_ts": 1789660000}}) assert [(e.state, e.result) for e in events] == [("paused", None), ("open", None), ("determined", "yes")] assert events[2].timestamp == 1789660000000 def test_no_leg_view(self, kalshi): - kalshi.handle({"type": "orderbook_snapshot", "sid": 1, "seq": 1, "msg": { + kalshi._handle_decoded_message({"type": "orderbook_snapshot", "sid": 1, "seq": 1, "msg": { "market_ticker": "A", "yes_dollars_fp": [["0.4000", "5.00"]], "no_dollars_fp": [["0.5500", "3.00"]]}}) assert kalshi.book("kalshi:A").best_ask == D("0.4500") no = kalshi.book("A", side="no") @@ -472,7 +516,7 @@ def replay_poly(stream: PolymarketMarketStream, rows: list[dict], *, skip=lambda for i, row in enumerate(rows): if row["dir"] == "in" and not skip(i, row): msg = row["msg"] - stream._dispatch(msg if isinstance(msg, str) else json.dumps(msg)) + stream._process_raw_frame_and_emit_events(msg if isinstance(msg, str) else json.dumps(msg)) class TestPolymarketMarket: @@ -546,7 +590,7 @@ async def test_subscription_frames(self): def test_lifecycle_and_tick_size(self): stream = PolymarketMarketStream() - events = stream.handle([ + events = stream._handle_decoded_message([ {"event_type": "market_resolved", "market": "0xm", "winning_asset_id": "1", "timestamp": "1789659000000"}, {"event_type": "tick_size_change", "market": "0xm", "asset_id": "1", "old_tick_size": "0.01", "new_tick_size": "0.001", "timestamp": "1789659000000"}, {"event_type": "best_bid_ask", "market": "0xm", "asset_id": "1", "best_bid": "0.4", "best_ask": "0.41", "timestamp": "1789659000000"}, @@ -603,11 +647,11 @@ async def ensure_api_credentials(self): def test_orders_and_settling_fills(self): stream = PolymarketUserStream(api_key="api-key", api_secret="s", api_passphrase="p", wallets={"0xwallet"}) - placed, cancelled = stream.handle([DOC_USER_ORDER, {**DOC_USER_ORDER, "type": "CANCELLATION"}]) + placed, cancelled = stream._handle_decoded_message([DOC_USER_ORDER, {**DOC_USER_ORDER, "type": "CANCELLATION"}]) assert placed.order.status == OrderStatus.OPEN and placed.native == "PLACEMENT" assert cancelled.order.status == OrderStatus.CANCELED and cancelled.order.remaining == 0 - (matched,) = stream.handle(DOC_USER_TRADE) - (confirmed,) = stream.handle({**DOC_USER_TRADE, "status": "CONFIRMED"}) + (matched,) = stream._handle_decoded_message(DOC_USER_TRADE) + (confirmed,) = stream._handle_decoded_message({**DOC_USER_TRADE, "status": "CONFIRMED"}) assert matched.fill.id == confirmed.fill.id == "trade-1:0xorder" assert matched.fill.settlement == SettlementState.MATCHED and confirmed.fill.settlement == SettlementState.CONFIRMED assert matched.fill.quantity == D("4") and matched.fill.side == Side.SELL @@ -659,23 +703,23 @@ def test_market_data_is_a_snapshot_and_state_changes_once(self, us_creds): "bids": [{"px": {"value": "0.555", "currency": "USD"}, "qty": "0.50"}, {"px": {"value": "0.550", "currency": "USD"}, "qty": "2.50"}], "offers": [{"px": {"value": "0.560", "currency": "USD"}, "qty": "0.80"}], "state": "MARKET_STATE_OPEN", "stats": {"lastTradePx": {"value": "0.55"}}, "transactTime": "2026-09-17T12:00:00Z"}} - book, status = stream.handle(message) + book, status = stream._handle_decoded_message(message) assert isinstance(book, BookEvent) and book.kind == "snapshot" and book.best_bid == D("0.555") and book.best_ask == D("0.560") assert isinstance(status, MarketStatusEvent) and status.state == "open" - (again,) = stream.handle(message) + (again,) = stream._handle_decoded_message(message) assert isinstance(again, BookEvent) - _, halted = stream.handle({**message, "marketData": {**message["marketData"], "state": "MARKET_STATE_HALTED"}}) + _, halted = stream._handle_decoded_message({**message, "marketData": {**message["marketData"], "state": "MARKET_STATE_HALTED"}}) assert halted.state == "paused" and halted.native == "MARKET_STATE_HALTED" assert stream.book(SLUG, side="no").best_bid == D("0.440") def test_lite_trades_heartbeats_and_errors(self, us_creds): stream = PolymarketUSMarketStream(us_creds) - (quote,) = stream.handle({"marketDataLite": {"marketSlug": SLUG, "bestBid": {"value": "0.54"}, "bestAsk": {"value": "0.56"}, "lastTradePx": {"value": "0.55"}}}) - (trade,) = stream.handle({"trade": {"marketSlug": SLUG, "price": {"value": "0.555"}, "quantity": {"value": "0.50"}, + (quote,) = stream._handle_decoded_message({"marketDataLite": {"marketSlug": SLUG, "bestBid": {"value": "0.54"}, "bestAsk": {"value": "0.56"}, "lastTradePx": {"value": "0.55"}}}) + (trade,) = stream._handle_decoded_message({"trade": {"marketSlug": SLUG, "price": {"value": "0.555"}, "quantity": {"value": "0.50"}, "tradeTime": "2026-09-17T12:00:00Z", "maker": {"side": "ORDER_SIDE_BUY"}, "taker": {"side": "ORDER_SIDE_SELL"}}}) assert quote.bid == D("0.54") and trade.quantity == D("0.50") and trade.taker_side == Side.SELL - assert stream.handle({"heartbeat": {}}) == [] - assert stream.handle({"requestId": "md-1", "error": "unknown market"}) == [] + assert stream._handle_decoded_message({"heartbeat": {}}) == [] + assert stream._handle_decoded_message({"requestId": "md-1", "error": "unknown market"}) == [] (err,) = of(drain(stream), StreamStatusEvent) assert err.state == "error" and err.key == "md-1" @@ -684,25 +728,25 @@ def test_private_orders_fills_positions_balances(self, us_creds): order = {"id": "o1", "marketSlug": SLUG, "side": "ORDER_SIDE_SELL", "type": "ORDER_TYPE_LIMIT", "price": {"value": "0.17"}, "quantity": 10, "leavesQuantity": 6, "cumQuantity": 4, "state": "ORDER_STATE_PARTIALLY_FILLED", "intent": "ORDER_INTENT_BUY_SHORT", "tif": "TIME_IN_FORCE_GOOD_TILL_CANCEL"} - (snap,) = stream.handle({"requestId": "order-1", "orderSubscriptionSnapshot": {"orders": [order], "eof": True}}) + (snap,) = stream._handle_decoded_message({"requestId": "order-1", "orderSubscriptionSnapshot": {"orders": [order], "eof": True}}) assert snap.native == "snapshot" and snap.order.market_id == f"polymarket_us:{SLUG}" assert snap.order.side == Side.SELL and snap.order.price == D("0.17") - update, fill = stream.handle({"orderSubscriptionUpdate": {"execution": { + update, fill = stream._handle_decoded_message({"orderSubscriptionUpdate": {"execution": { "id": "e1", "order": order, "lastShares": "4", "lastPx": {"value": "0.16"}, "type": "EXECUTION_TYPE_PARTIAL_FILL", "tradeId": "t1", "aggressor": True, "transactTime": "2026-09-17T12:00:00Z"}}}) assert isinstance(update, OrderEvent) and update.native == "EXECUTION_TYPE_PARTIAL_FILL" assert isinstance(fill, FillEvent) and fill.fill.id == "t1" and fill.fill.price == D("0.16") and fill.fill.side == Side.SELL assert fill.fill.liquidity == Liquidity.TAKER and fill.fill.market_id == f"polymarket_us:{SLUG}" - (cancel,) = stream.handle({"orderUpdate": {"execution": {"id": "e2", "order": {**order, "state": "ORDER_STATE_CANCELED"}, "type": "EXECUTION_TYPE_CANCELED"}}}) + (cancel,) = stream._handle_decoded_message({"orderUpdate": {"execution": {"id": "e2", "order": {**order, "state": "ORDER_STATE_CANCELED"}, "type": "EXECUTION_TYPE_CANCELED"}}}) assert cancel.order.status == OrderStatus.CANCELED - (position,) = stream.handle({"positionSubscription": { + (position,) = stream._handle_decoded_message({"positionSubscription": { "marketSlug": SLUG, "beforePosition": {"netPositionDecimal": "1.0000"}, "afterPosition": {"netPositionDecimal": "-2.0000", "cost": {"value": "-0.34"}}, "updateTime": "2026-09-17T12:00:00Z", "entryType": "LEDGER_ENTRY_TYPE_ORDER_EXECUTION"}}) assert isinstance(position, PositionEvent) and position.position.side == PositionSide.SHORT and position.position.contracts == D("2") - (docs_balance,) = stream.handle({"accountBalancesSnapshot": {"balances": [{"currentBalance": 1000.0, "currency": "USD", "buyingPower": 850.0}]}}) - (sdk_balance,) = stream.handle({"accountBalanceSubscriptionUpdate": {"balance": 990.5, "buyingPower": 840.5}}) - (change,) = stream.handle({"accountBalancesUpdate": {"balanceChange": {"afterBalance": {"currentBalance": 980, "buyingPower": 830}}}}) + (docs_balance,) = stream._handle_decoded_message({"accountBalancesSnapshot": {"balances": [{"currentBalance": 1000.0, "currency": "USD", "buyingPower": 850.0}]}}) + (sdk_balance,) = stream._handle_decoded_message({"accountBalanceSubscriptionUpdate": {"balance": 990.5, "buyingPower": 840.5}}) + (change,) = stream._handle_decoded_message({"accountBalancesUpdate": {"balanceChange": {"afterBalance": {"currentBalance": 980, "buyingPower": 830}}}}) assert isinstance(docs_balance, BalanceEvent) and docs_balance.balance.buying_power == D("850.0") assert sdk_balance.balance.total == D("990.5") and change.balance.available == D("830")