diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index 07d5b791..fab262b0 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -15,6 +15,8 @@ sdk/open_api/models/account.py sdk/open_api/models/account_balance.py sdk/open_api/models/account_type.py sdk/open_api/models/asset_definition.py +sdk/open_api/models/cancel_all_after_request.py +sdk/open_api/models/cancel_all_after_response.py sdk/open_api/models/cancel_order_request.py sdk/open_api/models/cancel_order_response.py sdk/open_api/models/candle_history_data.py @@ -33,6 +35,8 @@ sdk/open_api/models/market_definition.py sdk/open_api/models/market_summary.py sdk/open_api/models/mass_cancel_request.py sdk/open_api/models/mass_cancel_response.py +sdk/open_api/models/modify_order_request.py +sdk/open_api/models/modify_order_response.py sdk/open_api/models/order.py sdk/open_api/models/order_status.py sdk/open_api/models/order_type.py diff --git a/CLAUDE.md b/CLAUDE.md index 582a7141..beed15ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,8 +39,16 @@ python -m examples.websocket.market_monitoring python -m examples.rpc.trade_execution ``` +## Test tree layout (one axis per directory) +* `tests/engine/` — market-AGNOSTIC matching-engine behavior, every test parametrized `[spot, perp]` via the root-conftest `market_config`/`maker`/`taker` fixtures (lazy: a one-market env never spins up the other market's sessions). This is the home for ALL shared engine behavior including the feature suites — lifecycle (GTC/IOC/cancel/SMP), plus `test_modify_*`, `test_cod_*`, `test_post_only_*`. Select a feature with its marker: `-m modify` / `-m cod` / `-m post_only`. Fill-producing modules assert settlement via the injected `settlement_probe` (spot balance deltas / perp position deltas; `tests/helpers/settlement.py`) and wire per-market cleanup via the autouse `settlement_cleanup_guard`. +* `tests/spot/` — spot PHYSICS only (balance deltas, conservation, spotExecutions surfaces, pre-trade balance checks). `tests/perp/` — perp physics (baseline-relative positions, reduce-only, trigger orders). +* `tests/api_contract/` — raw EIP-712/nonce/deadline envelope validation; live but never trades (no balance/position guards). Pinned to the spot market + a 2-test perp cross-market smoke. +* `tests/ws_exec/` — WS order-entry transport (session semantics, error envelopes). +* `tests/parity/` + `tests/validation/` — OFFLINE (no network), selectable via `pytest -m offline` (also by path, unchanged). +* Selection: `-m spot` / `-m perp` are auto-derived from param ids + hand markers; `-m modify|cod|post_only` cross-cut the feature tests in `engine/` (and their offline guard twins in `validation/`). + ## Testing against devnet -* The integration suites (`tests/test_orderbook`, `test_perps`, `test_spot`, `ws_exec`) run **live against devnet** — they place real orders, fill, settle on-chain, and assert on executions/balances. +* The live suites (`tests/engine`, `tests/spot`, `tests/perp`, `tests/api_contract`, `tests/ws_exec`) run **live against devnet** — they place real orders, fill, settle on-chain, and assert on executions/balances. * **Before running the suite, kill any long-running example scripts** (e.g. `examples.websocket.perps.depth_market_maker`, any `python -m examples.*`). They maintain resting orders / open positions on the shared devnet test accounts and **pollute test state** — symptoms include `cancelledCount` mismatches, "reduce-only not rejected" (a leftover position exists), and matching against the wrong counterparty. Check with `ps -Ao pid,etime,command | grep -iE "examples\.|market_maker"` and kill stragglers before a run. * Tests share a small pool of devnet accounts; leftover orders from a crashed/aborted run can also pollute — a clean run starts from no resting orders / no open positions on the test accounts. diff --git a/examples/rest_api/perps/markets_example.py b/examples/rest_api/perps/markets_example.py index 09a38e29..63593b68 100644 --- a/examples/rest_api/perps/markets_example.py +++ b/examples/rest_api/perps/markets_example.py @@ -29,7 +29,7 @@ async def main(): # Get markets configuration print("\n--- Getting markets configuration ---") - config = await client.reference.get_market_definitions() + config = await client.reference.get_perp_market_definitions() print(f"Markets configuration: {config}") symbol = "ETHRUSDPERP" diff --git a/examples/rest_api/spot/create_orders.py b/examples/rest_api/spot/create_orders.py index 18761630..a3e2cb0f 100644 --- a/examples/rest_api/spot/create_orders.py +++ b/examples/rest_api/spot/create_orders.py @@ -2,11 +2,13 @@ """ Create Orders - Creates a buy and sell order at extreme prices. -This script demonstrates how to place GTC limit orders that will sit in the order book: +This script demonstrates how to place resting limit orders that sit in the order book: 1. BUY order at $10 (far below market - will not fill immediately) 2. SELL order at $1,000,000 (far above market - will not fill immediately) -Both orders are for 0.001 ETH. +Both orders are for 0.001 ETH. The time-in-force is derived from +``ORDER_EXPIRY_SECONDS``: an expiry makes them GTT (rest, then auto-expire at +``expires_after``); ``None`` makes them GTC (rest until explicitly cancelled). Requirements: - CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) @@ -40,7 +42,10 @@ TRADE_QTY = "0.0001" BUY_PRICE = "1" # $1 - safely below any realistic ETH bid, will rest SELL_PRICE = "1000000" # $1M - safely above any realistic ETH ask, will rest -ORDER_EXPIRY_SECONDS = 120 # Orders expire after 120s; set to None for the SDK default (24h on spot GTC) +# An expiry makes the orders GTT (auto-expire after this many seconds); it must +# be longer than the ~60s signature deadline. Set to None for a true GTC order +# that rests until you cancel it. +ORDER_EXPIRY_SECONDS = 120 # ============================================================================= # LOGGING SETUP @@ -94,10 +99,12 @@ async def main() -> None: logger.info(f"Buy Price: ${BUY_PRICE}") logger.info(f"Sell Price: ${SELL_PRICE}") logger.info(f"Account ID: {account_id}") + # An expiry => GTT (rest, then auto-expire); no expiry => GTC (rest forever). + order_tif = TimeInForce.GTT if ORDER_EXPIRY_SECONDS else TimeInForce.GTC if ORDER_EXPIRY_SECONDS: - logger.info(f"Order Expiry: {ORDER_EXPIRY_SECONDS}s per order") + logger.info(f"Time-in-force: GTT (auto-expires {ORDER_EXPIRY_SECONDS}s after placement)") else: - logger.info("Order Expiry: default (24h)") + logger.info("Time-in-force: GTC (rests until cancelled)") logger.info("=" * 60) def expires_now() -> int | None: @@ -114,32 +121,32 @@ def expires_now() -> int | None: await client.start() logger.info("✅ Client initialized") - # Place GTC BUY order + # Place BUY order logger.info("-" * 60) - logger.info(f"📈 Placing GTC BUY order: {TRADE_QTY} ETH @ ${BUY_PRICE}") + logger.info(f"📈 Placing {order_tif.value} BUY order: {TRADE_QTY} ETH @ ${BUY_PRICE}") buy_params = LimitOrderParameters( symbol=SPOT_SYMBOL, is_buy=True, qty=TRADE_QTY, limit_px=BUY_PRICE, - time_in_force=TimeInForce.GTC, + time_in_force=order_tif, expires_after=expires_now(), ) buy_response = await client.create_limit_order(buy_params) logger.info(f"✅ BUY order placed: Order ID = {buy_response.order_id}") - # Place GTC SELL order + # Place SELL order logger.info("-" * 60) - logger.info(f"📉 Placing GTC SELL order: {TRADE_QTY} ETH @ ${SELL_PRICE}") + logger.info(f"📉 Placing {order_tif.value} SELL order: {TRADE_QTY} ETH @ ${SELL_PRICE}") sell_params = LimitOrderParameters( symbol=SPOT_SYMBOL, is_buy=False, qty=TRADE_QTY, limit_px=SELL_PRICE, - time_in_force=TimeInForce.GTC, + time_in_force=order_tif, expires_after=expires_now(), ) diff --git a/examples/rest_api/spot/verify_rate_limits.py b/examples/rest_api/spot/verify_rate_limits.py index f36572fa..8544ca95 100644 --- a/examples/rest_api/spot/verify_rate_limits.py +++ b/examples/rest_api/spot/verify_rate_limits.py @@ -20,7 +20,6 @@ import asyncio import logging -import time from dotenv import load_dotenv @@ -146,7 +145,7 @@ async def test_b_open_order_count_cap(): logger.info("Waiting 65s for rate limit window to reset after cleanup...") await asyncio.sleep(65) - expires_after = int(time.time()) + 300 # 5 min expiry + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) # Place 3 GTC orders — all should succeed logger.info("Placing 3 GTC orders (should all succeed)...") @@ -208,7 +207,7 @@ async def test_b_open_order_count_cap(): await asyncio.sleep(65) logger.info("Placing new GTC order after cancel (should succeed — 2 resting, cap is 3)...") - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) params = LimitOrderParameters( symbol=SYMBOL, is_buy=True, @@ -269,7 +268,7 @@ async def test_c_rate_limit_shared_across_operations(): logger.info("Waiting 65s for rate limit window to reset...") await asyncio.sleep(65) - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) order_ids = [] # Step 1: Place 2 GTC orders (2/5 rate limit, 2/3 cap) @@ -417,7 +416,7 @@ async def test_d_mass_cancel_separate_bucket(): logger.info("Waiting 65s for both rate limit windows to reset...") await asyncio.sleep(65) - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) # Step 1: Place 2 GTC orders logger.info("") @@ -451,7 +450,7 @@ async def test_d_mass_cancel_separate_bucket(): # Step 3: Place 2 GTC orders logger.info("") logger.info("Step 3: Placing 2 GTC orders...") - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) for i in range(1, 3): params = LimitOrderParameters( symbol=SYMBOL, @@ -581,7 +580,7 @@ async def test_e_whitelisted_higher_cap(): logger.info("Wallet 1 (regular) — cap should be 3") logger.info("-" * 60) - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) logger.info("Placing 3 GTC orders (should all succeed)...") for i in range(1, 4): @@ -624,7 +623,7 @@ async def test_e_whitelisted_higher_cap(): logger.info("Wallet 2 (whitelisted) — cap should be 5") logger.info("-" * 60) - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) logger.info("Placing 5 GTC orders (should all succeed — whitelisted cap is 5)...") for i in range(1, 6): @@ -711,7 +710,7 @@ async def test_f_open_notional_cap(): logger.info("Waiting 65s for rate limit window to reset...") await asyncio.sleep(65) - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) # Step 1: Place GTC buy at $1 for qty 2000 → notional = $2000 (under $3k cap) logger.info("") @@ -838,7 +837,7 @@ async def test_g_premium_wallet(): logger.info("Waiting 65s for rate limit windows to reset...") await asyncio.sleep(65) - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) # Step 1+2: place 6 GTC orders, each notional = $1 × 1000 = $1000 # Totals: 6 orders (above regular count cap 3) and $6k notional @@ -981,7 +980,7 @@ async def test_h_premium_mass_cancel(): mass_cancels_accepted = 0 for i in range(1, 5): - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) params = LimitOrderParameters( symbol=SYMBOL, is_buy=True, @@ -1033,7 +1032,7 @@ async def test_h_premium_mass_cancel(): # Step 3: independent order bucket — place another GTC order logger.info("") logger.info("Step 3: Placing one more GTC order (order bucket should still have headroom — 4/7)") - expires_after = int(time.time()) + 300 + expires_after = 0 # true GTC: rests until cancelled (probe cancels explicitly) params = LimitOrderParameters( symbol=SYMBOL, is_buy=True, diff --git a/examples/websocket/perps/depth_market_maker.py b/examples/websocket/perps/depth_market_maker.py index 270063c8..5e41ed29 100644 --- a/examples/websocket/perps/depth_market_maker.py +++ b/examples/websocket/perps/depth_market_maker.py @@ -98,11 +98,12 @@ # Settle asset on Reya is rUSD across all envs at the time of writing. COLLATERAL_ASSET = "RUSD" -# GTC orders are signed with a long-lived `expires_after` so the matching -# engine doesn't quietly cancel resting depth before the next replace cycle. -# 10 minutes is well above any single cycle's batch of placements + WS -# round-trip slack, and at the off-chain api's documented deadline cap. -GTC_LIFETIME_S = 60 * 10 +# Resting depth is posted as GTT (Good-Till-Time): it rests like GTC but the +# matching engine auto-reaps it at `expires_after`, so a stale quote is cleaned +# up if a replace cycle is missed. 10 minutes is well above any single cycle's +# batch of placements + WS round-trip slack. (A true GTC would rest forever +# until explicitly cancelled — `expires_after=0`.) +GTT_LIFETIME_S = 60 * 10 @dataclass @@ -417,7 +418,7 @@ def on_close(self, _ws: ReyaSocket, close_status_code: int, close_msg: str) -> N async def fetch_market_definition(client: ReyaTradingClient, symbol: str) -> MarketParams: """Look up perp market params via ``/marketDefinitions``.""" - definitions = await client.reference.get_market_definitions() + definitions = await client.reference.get_perp_market_definitions() for market in definitions: if market.symbol == symbol: return MarketParams( @@ -511,16 +512,15 @@ async def place_single_order( for attempt in range(max_retries): try: - deadline = int(time.time()) + GTC_LIFETIME_S + expires_after = int(time.time()) + GTT_LIFETIME_S await client.create_limit_order( LimitOrderParameters( symbol=symbol, is_buy=is_buy, limit_px=price, qty=qty, - time_in_force=TimeInForce.GTC, - expires_after=deadline, - deadline=deadline, + time_in_force=TimeInForce.GTT, + expires_after=expires_after, ) ) logger.info(f" Placed {side} @ ${price} qty={qty}") @@ -628,16 +628,15 @@ async def cancel_and_replace_order( qty_to_use = new_qty for attempt in range(max_retries): try: - deadline = int(time.time()) + GTC_LIFETIME_S + expires_after = int(time.time()) + GTT_LIFETIME_S await client.create_limit_order( LimitOrderParameters( symbol=symbol, is_buy=order.is_buy, limit_px=str(new_price), qty=qty_to_use, - time_in_force=TimeInForce.GTC, - expires_after=deadline, - deadline=deadline, + time_in_force=TimeInForce.GTT, + expires_after=expires_after, ) ) return True diff --git a/examples/websocket/spot/depth_market_maker.py b/examples/websocket/spot/depth_market_maker.py index 5cc024bd..2331d1c2 100644 --- a/examples/websocket/spot/depth_market_maker.py +++ b/examples/websocket/spot/depth_market_maker.py @@ -72,11 +72,12 @@ STATE_REFRESH_CYCLES = 30 # Refresh state from REST every N cycles to handle WS disconnects MIN_BASE_BALANCE = Decimal("0.1") # Minimum ETH balance - stop MM if below this -# GTC orders are signed with a long-lived `expires_after` so the matching -# engine doesn't quietly cancel resting depth before the next replace cycle. -# 10 minutes is well above any single cycle's batch of placements + WS -# round-trip slack, and at the off-chain api's documented deadline cap. -GTC_LIFETIME_S = 60 * 10 +# Resting depth is posted as GTT (Good-Till-Time): it rests like GTC but the +# matching engine auto-reaps it at `expires_after`, so a stale quote is cleaned +# up if a replace cycle is missed. 10 minutes is well above any single cycle's +# batch of placements + WS round-trip slack. (A true GTC would rest forever +# until explicitly cancelled — `expires_after=0`.) +GTT_LIFETIME_S = 60 * 10 @dataclass @@ -545,16 +546,15 @@ async def place_single_order( for attempt in range(max_retries): try: - deadline = int(time.time()) + GTC_LIFETIME_S + expires_after = int(time.time()) + GTT_LIFETIME_S await client.create_limit_order( LimitOrderParameters( symbol=symbol, is_buy=is_buy, limit_px=price, qty=qty, - time_in_force=TimeInForce.GTC, - expires_after=deadline, - deadline=deadline, + time_in_force=TimeInForce.GTT, + expires_after=expires_after, ) ) logger.info(f" Adding {side} @ ${price} qty={qty}") @@ -727,16 +727,15 @@ async def cancel_and_replace_order( qty_to_use = new_qty for attempt in range(max_retries): try: - deadline = int(time.time()) + GTC_LIFETIME_S + expires_after = int(time.time()) + GTT_LIFETIME_S await client.create_limit_order( LimitOrderParameters( symbol=symbol, is_buy=order.is_buy, limit_px=str(new_price), qty=qty_to_use, - time_in_force=TimeInForce.GTC, - expires_after=deadline, - deadline=deadline, + time_in_force=TimeInForce.GTT, + expires_after=expires_after, ) ) return True diff --git a/pyproject.toml b/pyproject.toml index d2df592d..9ff9d558 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "reya-python-sdk" -version = "3.0.1.0" +version = "3.0.5.0" description = "SDK for interacting with Reya Labs APIs" authors = [ {name = "Reya Labs"} @@ -168,10 +168,8 @@ markers = [ "spot: Spot trading tests (ETHRUSD, BTCRUSD)", "perp: Perpetual trading tests (ETHRUSDPERP, BTCRUSDPERP)", "market_data: Market data endpoint tests", - "slow: Tests that take longer than 5 seconds", "maker_taker: Tests requiring two accounts (maker and taker)", "websocket: Tests that verify WebSocket events", - "rest: Tests that verify REST API responses", "e2e: Full end-to-end flow tests", "ioc: Immediate-Or-Cancel order tests", "gtc: Good-Till-Cancelled order tests", @@ -181,5 +179,9 @@ markers = [ "validation: API validation tests (signature, nonce, deadline, etc.)", "error: Error handling tests", "rest_api: REST API endpoint tests", - "bust: Deliberate spot execution bust tests", + "cod: Cancel-on-disconnect (cancelAllAfter dead-man's-switch) tests", + "modify: In-place order modification (modifyOrder) tests", + "post_only: Post-only (maker-only) order tests", + "gtt: Good-Till-Time (auto-expiring resting order) tests", + "offline: No-network tests (parity golden vectors + client guards) — safe without devnet/.env", ] diff --git a/scripts/probe_perp_gate.py b/scripts/probe_perp_gate.py index a422e1b0..876d19e8 100644 --- a/scripts/probe_perp_gate.py +++ b/scripts/probe_perp_gate.py @@ -23,7 +23,7 @@ async def main() -> None: client = ReyaTradingClient() await client.start() try: - defs = await client.reference.get_market_definitions() + defs = await client.reference.get_perp_market_definitions() perp_defs = [d for d in defs if d.symbol.endswith("PERP")] print(f"Found {len(perp_defs)} perp market(s): {[d.symbol for d in perp_defs]}") diff --git a/sdk/async_api/order.py b/sdk/async_api/order.py index 7f3d3a65..8c67aa24 100644 --- a/sdk/async_api/order.py +++ b/sdk/async_api/order.py @@ -18,7 +18,9 @@ class Order(BaseModel): order_type: OrderType = Field(description='''Order type aligned with the on-chain `OrderDetails.orderType` enum: LIMIT = limit order, STOP_LOSS = stop-loss trigger order, TAKE_PROFIT = take-profit trigger order.''', alias='''orderType''') trigger_px: Optional[str] = Field(default=None, alias='''triggerPx''') time_in_force: Optional[TimeInForce] = Field(description='''Order time in force (IOC = Immediate or Cancel, GTC = Good Till Cancel, GTT = Good Till Time)''', default=None, alias='''timeInForce''') + expires_after: Optional[int] = Field(default=None, alias='''expiresAfter''') reduce_only: Optional[bool] = Field(description='''Whether this is a reduce-only order, exclusively used for LIMIT IOC orders.''', default=None, alias='''reduceOnly''') + post_only: Optional[bool] = Field(description='''Whether this is a post-only (maker-only) order. Mirrors `CreateOrderRequest.postOnly`; updated by `modifyOrder`.''', default=None, alias='''postOnly''') status: OrderStatus = Field(description='''Order status''') created_at: int = Field(alias='''createdAt''') last_update_at: int = Field(alias='''lastUpdateAt''') @@ -42,13 +44,13 @@ def unwrap_additional_properties(cls, data): if not isinstance(data, dict): data = data.model_dump() json_properties = list(data.keys()) - known_object_properties = ['exchange_id', 'symbol', 'account_id', 'order_id', 'qty', 'exec_qty', 'cum_qty', 'side', 'limit_px', 'order_type', 'trigger_px', 'time_in_force', 'reduce_only', 'status', 'created_at', 'last_update_at', 'additional_properties'] + known_object_properties = ['exchange_id', 'symbol', 'account_id', 'order_id', 'qty', 'exec_qty', 'cum_qty', 'side', 'limit_px', 'order_type', 'trigger_px', 'time_in_force', 'expires_after', 'reduce_only', 'post_only', 'status', 'created_at', 'last_update_at', 'additional_properties'] unknown_object_properties = [element for element in json_properties if element not in known_object_properties] # Ignore attempts that validate regular models, only when unknown input is used we add unwrap extensions if len(unknown_object_properties) == 0: return data - known_json_properties = ['exchangeId', 'symbol', 'accountId', 'orderId', 'qty', 'execQty', 'cumQty', 'side', 'limitPx', 'orderType', 'triggerPx', 'timeInForce', 'reduceOnly', 'status', 'createdAt', 'lastUpdateAt', 'additionalProperties'] + known_json_properties = ['exchangeId', 'symbol', 'accountId', 'orderId', 'qty', 'execQty', 'cumQty', 'side', 'limitPx', 'orderType', 'triggerPx', 'timeInForce', 'expiresAfter', 'reduceOnly', 'postOnly', 'status', 'createdAt', 'lastUpdateAt', 'additionalProperties'] additional_properties = data.get('additional_properties', {}) for obj_key in unknown_object_properties: if not known_json_properties.__contains__(obj_key): diff --git a/sdk/async_exec_api/cancel_all_after_message_type.py b/sdk/async_exec_api/cancel_all_after_message_type.py new file mode 100644 index 00000000..ed1614f9 --- /dev/null +++ b/sdk/async_exec_api/cancel_all_after_message_type.py @@ -0,0 +1,4 @@ +from enum import Enum + +class CancelAllAfterMessageType(Enum): + CANCEL_ALL_AFTER = "cancelAllAfter" \ No newline at end of file diff --git a/sdk/async_exec_api/cancel_all_after_request.py b/sdk/async_exec_api/cancel_all_after_request.py new file mode 100644 index 00000000..5c9bd7ae --- /dev/null +++ b/sdk/async_exec_api/cancel_all_after_request.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import model_serializer, model_validator, BaseModel, Field + +class CancelAllAfterRequest(BaseModel): + account_id: int = Field(alias='''accountId''') + timeout_ms: int = Field(alias='''timeoutMs''') + signature: str = Field(description='''EIP-712 signature over the `CancelAllAfter(uint64 verifyingChainId, uint64 deadline, CancelAllAfterDetails cancelAllAfter)` envelope, where `CancelAllAfterDetails(uint64 accountId, uint64 timeoutMs, uint64 nonce)`. See `docs/eip712.md` for the exact typehash string and signing algorithm.''') + nonce: str = Field(description='''Monotonically increasing per-signer nonce. A fresh nonce is required on every arm/refresh/disarm call; replayed nonces are rejected with `INVALID_NONCE_ERROR`.''') + signer_wallet: str = Field(alias='''signerWallet''') + deadline: int = Field() + additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) + + @model_serializer(mode='wrap') + def custom_serializer(self, handler): + serialized_self = handler(self) + additional_properties = getattr(self, "additional_properties") + if additional_properties is not None: + for key, value in additional_properties.items(): + # Never overwrite existing values, to avoid clashes + if not key in serialized_self: + serialized_self[key] = value + + return serialized_self + + @model_validator(mode='before') + @classmethod + def unwrap_additional_properties(cls, data): + if not isinstance(data, dict): + data = data.model_dump() + json_properties = list(data.keys()) + known_object_properties = ['account_id', 'timeout_ms', 'signature', 'nonce', 'signer_wallet', 'deadline', 'additional_properties'] + unknown_object_properties = [element for element in json_properties if element not in known_object_properties] + # Ignore attempts that validate regular models, only when unknown input is used we add unwrap extensions + if len(unknown_object_properties) == 0: + return data + + known_json_properties = ['accountId', 'timeoutMs', 'signature', 'nonce', 'signerWallet', 'deadline', 'additionalProperties'] + additional_properties = data.get('additional_properties', {}) + for obj_key in unknown_object_properties: + if not known_json_properties.__contains__(obj_key): + additional_properties[obj_key] = data.pop(obj_key, None) + data['additional_properties'] = additional_properties + return data + diff --git a/sdk/async_exec_api/cancel_all_after_request_message_payload.py b/sdk/async_exec_api/cancel_all_after_request_message_payload.py new file mode 100644 index 00000000..c88255fe --- /dev/null +++ b/sdk/async_exec_api/cancel_all_after_request_message_payload.py @@ -0,0 +1,9 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import BaseModel, Field +from sdk.async_exec_api.cancel_all_after_message_type import CancelAllAfterMessageType +from sdk.async_exec_api.cancel_all_after_request import CancelAllAfterRequest +class CancelAllAfterRequestMessagePayload(BaseModel): + type: CancelAllAfterMessageType = Field(description='''Message type for cancelAllAfter request and response''') + id: str = Field(description='''Client-chosen correlation identifier; must be unique across in-flight requests on the connection.''') + payload: CancelAllAfterRequest = Field(description='''Arms, refreshes, or disarms the account-scoped cancel-all-after countdown (dead-man's-switch / cancel-on-disconnect). While armed, the server mass-cancels all of the account's open orders (same scope as `POST /v2/cancelAll` with no `symbol` filter) if the countdown is not refreshed with another `cancelAllAfter` call before `timeoutMs` elapses. Refresh is explicit only: order-entry traffic, WebSocket protocol pings, and app-level `ping`/`pong` frames do NOT refresh the countdown, and closing a WebSocket connection does NOT trigger it — only countdown expiry does. The switch is transport-agnostic (arm over REST, refresh over WS, or vice versa) and survives reconnects until it fires or is disarmed. `accountId`, `timeoutMs`, and `nonce` are signed via EIP-712 into the `CancelAllAfter(uint64 verifyingChainId, uint64 deadline, CancelAllAfterDetails cancelAllAfter)` envelope, where `CancelAllAfterDetails(uint64 accountId, uint64 timeoutMs, uint64 nonce)` and `deadline` is the signature validity. See `docs/eip712.md` for the signing algorithm and exact typehash strings.''') diff --git a/sdk/async_exec_api/cancel_all_after_response.py b/sdk/async_exec_api/cancel_all_after_response.py new file mode 100644 index 00000000..1e6cff91 --- /dev/null +++ b/sdk/async_exec_api/cancel_all_after_response.py @@ -0,0 +1,42 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import model_serializer, model_validator, BaseModel, Field + +class CancelAllAfterResponse(BaseModel): + account_id: int = Field(alias='''accountId''') + timeout_ms: int = Field(alias='''timeoutMs''') + trigger_at: Optional[int] = Field(default=None, alias='''triggerAt''') + additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) + + @model_serializer(mode='wrap') + def custom_serializer(self, handler): + serialized_self = handler(self) + additional_properties = getattr(self, "additional_properties") + if additional_properties is not None: + for key, value in additional_properties.items(): + # Never overwrite existing values, to avoid clashes + if not key in serialized_self: + serialized_self[key] = value + + return serialized_self + + @model_validator(mode='before') + @classmethod + def unwrap_additional_properties(cls, data): + if not isinstance(data, dict): + data = data.model_dump() + json_properties = list(data.keys()) + known_object_properties = ['account_id', 'timeout_ms', 'trigger_at', 'additional_properties'] + unknown_object_properties = [element for element in json_properties if element not in known_object_properties] + # Ignore attempts that validate regular models, only when unknown input is used we add unwrap extensions + if len(unknown_object_properties) == 0: + return data + + known_json_properties = ['accountId', 'timeoutMs', 'triggerAt', 'additionalProperties'] + additional_properties = data.get('additional_properties', {}) + for obj_key in unknown_object_properties: + if not known_json_properties.__contains__(obj_key): + additional_properties[obj_key] = data.pop(obj_key, None) + data['additional_properties'] = additional_properties + return data + diff --git a/sdk/async_exec_api/cancel_all_after_response_message_payload.py b/sdk/async_exec_api/cancel_all_after_response_message_payload.py new file mode 100644 index 00000000..29b9d421 --- /dev/null +++ b/sdk/async_exec_api/cancel_all_after_response_message_payload.py @@ -0,0 +1,12 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import BaseModel, Field +from sdk.async_exec_api.cancel_all_after_message_type import CancelAllAfterMessageType +from sdk.async_exec_api.cancel_all_after_response import CancelAllAfterResponse +from sdk.async_exec_api.request_error import RequestError +class CancelAllAfterResponseMessagePayload(BaseModel): + type: CancelAllAfterMessageType = Field(description='''Message type for cancelAllAfter request and response''') + id: str = Field(description='''Echoes the request `id`.''') + ok: bool = Field(description='''True on success (with `payload`), false on failure (with `error`).''') + payload: Optional[CancelAllAfterResponse] = Field(default=None) + error: Optional[RequestError] = Field(default=None) diff --git a/sdk/async_exec_api/create_order_request.py b/sdk/async_exec_api/create_order_request.py index 39fad0df..c80834dc 100644 --- a/sdk/async_exec_api/create_order_request.py +++ b/sdk/async_exec_api/create_order_request.py @@ -14,7 +14,7 @@ class CreateOrderRequest(BaseModel): time_in_force: Optional[TimeInForce] = Field(description='''Order time in force (IOC = Immediate or Cancel, GTC = Good Till Cancel, GTT = Good Till Time)''', default=None, alias='''timeInForce''') trigger_px: Optional[str] = Field(default=None, alias='''triggerPx''') reduce_only: Optional[bool] = Field(description='''Reduce-only intent. Perp only; spot markets must set this to false. Maps to on-chain `OrderDetails.reduceOnly`.''', default=None, alias='''reduceOnly''') - post_only: Optional[bool] = Field(description='''Post-only (maker-only) intent: the order must rest and never cross as a taker. Valid on GTC/GTT; rejected on IOC. Maps to on-chain `OrderDetails.postOnly`.''', default=None, alias='''postOnly''') + post_only: Optional[bool] = Field(description='''Post-only (maker-only) intent: the order must rest and never cross as a taker. Valid on GTC/GTT; rejected on IOC. An order that would cross at insertion is rejected with `POST_ONLY_WOULD_CROSS_ERROR`. Maps to on-chain `OrderDetails.postOnly`.''', default=None, alias='''postOnly''') signature: str = Field(description='''EIP-712 signature over the `Order(uint256 verifyingChainId, uint256 deadline, OrderDetails order)` envelope. See `docs/eip712.md` for the exact typehash string and signing algorithm.''') nonce: str = Field(description='''Monotonically increasing per-signer nonce. Maps to on-chain `OrderDetails.nonce`.''') signer_wallet: str = Field(alias='''signerWallet''') diff --git a/sdk/async_exec_api/modify_order_message_type.py b/sdk/async_exec_api/modify_order_message_type.py new file mode 100644 index 00000000..807a781a --- /dev/null +++ b/sdk/async_exec_api/modify_order_message_type.py @@ -0,0 +1,4 @@ +from enum import Enum + +class ModifyOrderMessageType(Enum): + MODIFY_ORDER = "modifyOrder" \ No newline at end of file diff --git a/sdk/async_exec_api/modify_order_request.py b/sdk/async_exec_api/modify_order_request.py new file mode 100644 index 00000000..c57b2202 --- /dev/null +++ b/sdk/async_exec_api/modify_order_request.py @@ -0,0 +1,58 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import model_serializer, model_validator, BaseModel, Field +from sdk.async_exec_api.order_type import OrderType +from sdk.async_exec_api.time_in_force import TimeInForce +class ModifyOrderRequest(BaseModel): + order_id: Optional[str] = Field(description='''Internal matching engine order ID of the order to modify. Exactly one of `orderId` or `clientOrderId` must be provided.''', default=None, alias='''orderId''') + client_order_id: Optional[int] = Field(default=None, alias='''clientOrderId''') + symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') + account_id: int = Field(alias='''accountId''') + exchange_id: int = Field(alias='''exchangeId''') + is_buy: bool = Field(description='''Order side. Immutable — restate the resting order's value. Combined with `qty`, sets the signed `OrderDetails.quantity` (int256). A mismatch is rejected with `MODIFY_IMMUTABLE_MISMATCH`.''', alias='''isBuy''') + order_type: OrderType = Field(description='''Order type aligned with the on-chain `OrderDetails.orderType` enum: LIMIT = limit order, STOP_LOSS = stop-loss trigger order, TAKE_PROFIT = take-profit trigger order.''', alias='''orderType''') + time_in_force: Optional[TimeInForce] = Field(description='''Order time in force (IOC = Immediate or Cancel, GTC = Good Till Cancel, GTT = Good Till Time)''', default=None, alias='''timeInForce''') + trigger_px: Optional[str] = Field(default=None, alias='''triggerPx''') + reduce_only: Optional[bool] = Field(description='''On-chain `OrderDetails.reduceOnly`. Immutable — restate the resting order's value. A mismatch is rejected with `MODIFY_IMMUTABLE_MISMATCH`.''', default=None, alias='''reduceOnly''') + limit_px: str = Field(alias='''limitPx''') + qty: str = Field() + post_only: bool = Field(description='''The post-modify post-only (maker-only) flag. Always required — send the complete intended value even when it is unchanged from the resting order. If true and the post-modify order would cross, the modification is rejected with `POST_ONLY_WOULD_CROSS_ERROR` and the resting order is unchanged.''', alias='''postOnly''') + expires_after: int = Field(alias='''expiresAfter''') + signature: str = Field(description='''Fresh EIP-712 signature over the full post-modify order state — the same `Order` envelope as `createOrder`, with the modified values substituted into `OrderDetails`. See `docs/eip712.md` for the exact typehash string and signing algorithm.''') + nonce: str = Field(description='''Monotonically increasing per-signer nonce. A fresh nonce is required for every modification; replayed nonces are rejected with `INVALID_NONCE_ERROR`.''') + signer_wallet: str = Field(alias='''signerWallet''') + deadline: int = Field() + additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) + + @model_serializer(mode='wrap') + def custom_serializer(self, handler): + serialized_self = handler(self) + additional_properties = getattr(self, "additional_properties") + if additional_properties is not None: + for key, value in additional_properties.items(): + # Never overwrite existing values, to avoid clashes + if not key in serialized_self: + serialized_self[key] = value + + return serialized_self + + @model_validator(mode='before') + @classmethod + def unwrap_additional_properties(cls, data): + if not isinstance(data, dict): + data = data.model_dump() + json_properties = list(data.keys()) + known_object_properties = ['order_id', 'client_order_id', 'symbol', 'account_id', 'exchange_id', 'is_buy', 'order_type', 'time_in_force', 'trigger_px', 'reduce_only', 'limit_px', 'qty', 'post_only', 'expires_after', 'signature', 'nonce', 'signer_wallet', 'deadline', 'additional_properties'] + unknown_object_properties = [element for element in json_properties if element not in known_object_properties] + # Ignore attempts that validate regular models, only when unknown input is used we add unwrap extensions + if len(unknown_object_properties) == 0: + return data + + known_json_properties = ['orderId', 'clientOrderId', 'symbol', 'accountId', 'exchangeId', 'isBuy', 'orderType', 'timeInForce', 'triggerPx', 'reduceOnly', 'limitPx', 'qty', 'postOnly', 'expiresAfter', 'signature', 'nonce', 'signerWallet', 'deadline', 'additionalProperties'] + additional_properties = data.get('additional_properties', {}) + for obj_key in unknown_object_properties: + if not known_json_properties.__contains__(obj_key): + additional_properties[obj_key] = data.pop(obj_key, None) + data['additional_properties'] = additional_properties + return data + diff --git a/sdk/async_exec_api/modify_order_request_message_payload.py b/sdk/async_exec_api/modify_order_request_message_payload.py new file mode 100644 index 00000000..f6b9f75e --- /dev/null +++ b/sdk/async_exec_api/modify_order_request_message_payload.py @@ -0,0 +1,9 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import BaseModel, Field +from sdk.async_exec_api.modify_order_message_type import ModifyOrderMessageType +from sdk.async_exec_api.modify_order_request import ModifyOrderRequest +class ModifyOrderRequestMessagePayload(BaseModel): + type: ModifyOrderMessageType = Field(description='''Message type for modifyOrder request and response''') + id: str = Field(description='''Client-chosen correlation identifier; must be unique across in-flight requests on the connection.''') + payload: ModifyOrderRequest = Field(description='''Modifies a resting `LIMIT` order in place. The order keeps its `orderId` and `clientOrderId`. Target exactly one of `orderId` / `clientOrderId`; supplying both or neither is rejected with `INPUT_VALIDATION_ERROR`, and a target that does not resolve to a live resting order is rejected with `ORDER_NOT_FOUND`. **Full restate:** the request carries the SAME fields as `createOrder` (the complete post-modify `OrderDetails`) plus the target id — restate every value at its post-modify state, even unchanged ones; there is no omitted-means-inherited shorthand. The four modifiable fields are `limitPx`, `qty`, `postOnly`, `expiresAfter`. The remaining `OrderDetails` fields (`exchangeId`, `isBuy`/side, `orderType`, `triggerPx`, `timeInForce`, `reduceOnly`, `clientOrderId`, `accountId`, `signerWallet`) are IMMUTABLE: restate them at the resting order's values — the matching engine verifies each equals the resting order and rejects a mismatch with `MODIFY_IMMUTABLE_MISMATCH`. The EIP-712 signature is verified over exactly the restated fields (the same `Order` / `OrderDetails` envelope as `createOrder`), so it needs no resting-order lookup; a fresh `nonce` is required. A request whose post-modify state is identical to the order's current state is rejected with `EMPTY_MODIFY_ERROR`. Queue priority: a `qty` decrease at an unchanged `limitPx` preserves the order's place in the book; any `limitPx` change or `qty` increase loses it (re-queued at the new level). `qty` is the TOTAL order quantity, not remaining, and must be strictly greater than the order's `cumQty` (else `MODIFY_QTY_BELOW_FILLED_ERROR`). `timeInForce` is immutable — a modify cannot flip GTC↔GTT; the restated `timeInForce` must match the resting order and `expiresAfter` must stay consistent with it (`0` for GTC, a non-zero future timestamp for GTT). If the post-modify order is post-only and would cross, the modification is rejected with `POST_ONLY_WOULD_CROSS_ERROR` and the resting order is left untouched, priority intact. If it is not post-only and the new `limitPx` crosses, the modification executes immediately — the response reports the executed quantity (`execQty`) and resulting `status`, with per-fill detail delivered on the wallet executions and `walletOrderChanges` streams, exactly as for `createOrder`. See `docs/eip712.md` for the signing algorithm and exact typehash strings.''') diff --git a/sdk/async_exec_api/modify_order_response.py b/sdk/async_exec_api/modify_order_response.py new file mode 100644 index 00000000..ae270e2c --- /dev/null +++ b/sdk/async_exec_api/modify_order_response.py @@ -0,0 +1,44 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import model_serializer, model_validator, BaseModel, Field +from sdk.async_exec_api.order_status import OrderStatus +class ModifyOrderResponse(BaseModel): + status: OrderStatus = Field(description='''Order status''') + exec_qty: Optional[str] = Field(default=None, alias='''execQty''') + cum_qty: Optional[str] = Field(default=None, alias='''cumQty''') + order_id: str = Field(description='''Modified order ID — unchanged by the modification.''', alias='''orderId''') + client_order_id: Optional[int] = Field(default=None, alias='''clientOrderId''') + additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) + + @model_serializer(mode='wrap') + def custom_serializer(self, handler): + serialized_self = handler(self) + additional_properties = getattr(self, "additional_properties") + if additional_properties is not None: + for key, value in additional_properties.items(): + # Never overwrite existing values, to avoid clashes + if not key in serialized_self: + serialized_self[key] = value + + return serialized_self + + @model_validator(mode='before') + @classmethod + def unwrap_additional_properties(cls, data): + if not isinstance(data, dict): + data = data.model_dump() + json_properties = list(data.keys()) + known_object_properties = ['status', 'exec_qty', 'cum_qty', 'order_id', 'client_order_id', 'additional_properties'] + unknown_object_properties = [element for element in json_properties if element not in known_object_properties] + # Ignore attempts that validate regular models, only when unknown input is used we add unwrap extensions + if len(unknown_object_properties) == 0: + return data + + known_json_properties = ['status', 'execQty', 'cumQty', 'orderId', 'clientOrderId', 'additionalProperties'] + additional_properties = data.get('additional_properties', {}) + for obj_key in unknown_object_properties: + if not known_json_properties.__contains__(obj_key): + additional_properties[obj_key] = data.pop(obj_key, None) + data['additional_properties'] = additional_properties + return data + diff --git a/sdk/async_exec_api/modify_order_response_message_payload.py b/sdk/async_exec_api/modify_order_response_message_payload.py new file mode 100644 index 00000000..9f220571 --- /dev/null +++ b/sdk/async_exec_api/modify_order_response_message_payload.py @@ -0,0 +1,12 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import BaseModel, Field +from sdk.async_exec_api.modify_order_message_type import ModifyOrderMessageType +from sdk.async_exec_api.modify_order_response import ModifyOrderResponse +from sdk.async_exec_api.request_error import RequestError +class ModifyOrderResponseMessagePayload(BaseModel): + type: ModifyOrderMessageType = Field(description='''Message type for modifyOrder request and response''') + id: str = Field(description='''Echoes the request `id`.''') + ok: bool = Field(description='''True on success (with `payload`), false on failure (with `error`).''') + payload: Optional[ModifyOrderResponse] = Field(description='''Result of a modification, same shape as `CreateOrderResponse`. `orderId` is always the same ID the order had before the modification. If the modification crossed the book it executed immediately: `execQty` carries the quantity it filled and `status` reflects the outcome (`OPEN` for a partial fill leaving a remainder resting, `FILLED` for a complete fill). Per-fill detail (prices, fees) is delivered on the wallet executions and `walletOrderChanges` streams, exactly as for `createOrder`.''', default=None) + error: Optional[RequestError] = Field(default=None) diff --git a/sdk/async_exec_api/request_error_code.py b/sdk/async_exec_api/request_error_code.py index 1899a6b7..413f5f6d 100644 --- a/sdk/async_exec_api/request_error_code.py +++ b/sdk/async_exec_api/request_error_code.py @@ -12,4 +12,10 @@ class RequestErrorCode(Enum): INVALID_NONCE_ERROR = "INVALID_NONCE_ERROR" UNAVAILABLE_MATCHING_ENGINE_ERROR = "UNAVAILABLE_MATCHING_ENGINE_ERROR" UNAUTHORIZED_SIGNATURE_ERROR = "UNAUTHORIZED_SIGNATURE_ERROR" - NUMERIC_OVERFLOW_ERROR = "NUMERIC_OVERFLOW_ERROR" \ No newline at end of file + NUMERIC_OVERFLOW_ERROR = "NUMERIC_OVERFLOW_ERROR" + CANCEL_ALL_AFTER_OTHER_ERROR = "CANCEL_ALL_AFTER_OTHER_ERROR" + ORDER_NOT_FOUND = "ORDER_NOT_FOUND" + POST_ONLY_WOULD_CROSS_ERROR = "POST_ONLY_WOULD_CROSS_ERROR" + MODIFY_QTY_BELOW_FILLED_ERROR = "MODIFY_QTY_BELOW_FILLED_ERROR" + EMPTY_MODIFY_ERROR = "EMPTY_MODIFY_ERROR" + MODIFY_ORDER_OTHER_ERROR = "MODIFY_ORDER_OTHER_ERROR" \ No newline at end of file diff --git a/sdk/open_api/__init__.py b/sdk/open_api/__init__.py index 273ffd27..546f0bf7 100644 --- a/sdk/open_api/__init__.py +++ b/sdk/open_api/__init__.py @@ -7,14 +7,14 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. """ # noqa: E501 -__version__ = "3.0.1.0" +__version__ = "3.0.5.0" # Define package exports __all__ = [ @@ -36,6 +36,8 @@ "AccountBalance", "AccountType", "AssetDefinition", + "CancelAllAfterRequest", + "CancelAllAfterResponse", "CancelOrderRequest", "CancelOrderResponse", "CandleHistoryData", @@ -54,6 +56,8 @@ "MarketSummary", "MassCancelRequest", "MassCancelResponse", + "ModifyOrderRequest", + "ModifyOrderResponse", "Order", "OrderStatus", "OrderType", @@ -99,6 +103,8 @@ from sdk.open_api.models.account_balance import AccountBalance as AccountBalance from sdk.open_api.models.account_type import AccountType as AccountType from sdk.open_api.models.asset_definition import AssetDefinition as AssetDefinition +from sdk.open_api.models.cancel_all_after_request import CancelAllAfterRequest as CancelAllAfterRequest +from sdk.open_api.models.cancel_all_after_response import CancelAllAfterResponse as CancelAllAfterResponse from sdk.open_api.models.cancel_order_request import CancelOrderRequest as CancelOrderRequest from sdk.open_api.models.cancel_order_response import CancelOrderResponse as CancelOrderResponse from sdk.open_api.models.candle_history_data import CandleHistoryData as CandleHistoryData @@ -117,6 +123,8 @@ from sdk.open_api.models.market_summary import MarketSummary as MarketSummary from sdk.open_api.models.mass_cancel_request import MassCancelRequest as MassCancelRequest from sdk.open_api.models.mass_cancel_response import MassCancelResponse as MassCancelResponse +from sdk.open_api.models.modify_order_request import ModifyOrderRequest as ModifyOrderRequest +from sdk.open_api.models.modify_order_response import ModifyOrderResponse as ModifyOrderResponse from sdk.open_api.models.order import Order as Order from sdk.open_api.models.order_status import OrderStatus as OrderStatus from sdk.open_api.models.order_type import OrderType as OrderType diff --git a/sdk/open_api/api/market_data_api.py b/sdk/open_api/api/market_data_api.py index 18e99470..7cad76e5 100644 --- a/sdk/open_api/api/market_data_api.py +++ b/sdk/open_api/api/market_data_api.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/api/order_entry_api.py b/sdk/open_api/api/order_entry_api.py index f8cc5afa..3f1477a5 100644 --- a/sdk/open_api/api/order_entry_api.py +++ b/sdk/open_api/api/order_entry_api.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -17,12 +17,16 @@ from typing_extensions import Annotated from typing import Optional +from sdk.open_api.models.cancel_all_after_request import CancelAllAfterRequest +from sdk.open_api.models.cancel_all_after_response import CancelAllAfterResponse from sdk.open_api.models.cancel_order_request import CancelOrderRequest from sdk.open_api.models.cancel_order_response import CancelOrderResponse from sdk.open_api.models.create_order_request import CreateOrderRequest from sdk.open_api.models.create_order_response import CreateOrderResponse from sdk.open_api.models.mass_cancel_request import MassCancelRequest from sdk.open_api.models.mass_cancel_response import MassCancelResponse +from sdk.open_api.models.modify_order_request import ModifyOrderRequest +from sdk.open_api.models.modify_order_response import ModifyOrderResponse from sdk.open_api.api_client import ApiClient, RequestSerialized from sdk.open_api.api_response import ApiResponse @@ -61,7 +65,7 @@ async def cancel_all( ) -> MassCancelResponse: """Cancel all orders - Cancel all orders matching the specified filters (mass cancel). Supports both spot and perp markets. + Cancel all orders matching the specified filters (mass cancel). Supports both spot and perp markets. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `cancelAll`). :param mass_cancel_request: :type mass_cancel_request: MassCancelRequest @@ -130,7 +134,7 @@ async def cancel_all_with_http_info( ) -> ApiResponse[MassCancelResponse]: """Cancel all orders - Cancel all orders matching the specified filters (mass cancel). Supports both spot and perp markets. + Cancel all orders matching the specified filters (mass cancel). Supports both spot and perp markets. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `cancelAll`). :param mass_cancel_request: :type mass_cancel_request: MassCancelRequest @@ -199,7 +203,7 @@ async def cancel_all_without_preload_content( ) -> RESTResponseType: """Cancel all orders - Cancel all orders matching the specified filters (mass cancel). Supports both spot and perp markets. + Cancel all orders matching the specified filters (mass cancel). Supports both spot and perp markets. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `cancelAll`). :param mass_cancel_request: :type mass_cancel_request: MassCancelRequest @@ -321,6 +325,285 @@ def _cancel_all_serialize( + @validate_call + async def cancel_all_after( + self, + cancel_all_after_request: CancelAllAfterRequest, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> CancelAllAfterResponse: + """Set cancel-all-after countdown + + Arm, refresh, or disarm an account-scoped dead-man's-switch countdown. While armed, all open orders for `accountId` (same scope as `POST /v2/cancelAll` with no `symbol` filter) are mass-cancelled if the countdown is not refreshed with another call before `timeoutMs` elapses. `timeoutMs: 0` disarms; non-zero values must be within [5000, 60000] milliseconds. Refresh is explicit only — order entry, WebSocket ping/pong, and connection close neither refresh nor trigger the countdown. `accountId`, `timeoutMs`, and `nonce` are bound into the EIP-712 `CancelAllAfter` signature; `deadline` is the signature-validity window (Unix seconds), not the countdown trigger time. The response returns the effective trigger timestamp (`triggerAt`, milliseconds; omitted when disarming). Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `cancelAllAfter`). + + :param cancel_all_after_request: (required) + :type cancel_all_after_request: CancelAllAfterRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._cancel_all_after_serialize( + cancel_all_after_request=cancel_all_after_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "CancelAllAfterResponse", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def cancel_all_after_with_http_info( + self, + cancel_all_after_request: CancelAllAfterRequest, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[CancelAllAfterResponse]: + """Set cancel-all-after countdown + + Arm, refresh, or disarm an account-scoped dead-man's-switch countdown. While armed, all open orders for `accountId` (same scope as `POST /v2/cancelAll` with no `symbol` filter) are mass-cancelled if the countdown is not refreshed with another call before `timeoutMs` elapses. `timeoutMs: 0` disarms; non-zero values must be within [5000, 60000] milliseconds. Refresh is explicit only — order entry, WebSocket ping/pong, and connection close neither refresh nor trigger the countdown. `accountId`, `timeoutMs`, and `nonce` are bound into the EIP-712 `CancelAllAfter` signature; `deadline` is the signature-validity window (Unix seconds), not the countdown trigger time. The response returns the effective trigger timestamp (`triggerAt`, milliseconds; omitted when disarming). Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `cancelAllAfter`). + + :param cancel_all_after_request: (required) + :type cancel_all_after_request: CancelAllAfterRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._cancel_all_after_serialize( + cancel_all_after_request=cancel_all_after_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "CancelAllAfterResponse", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def cancel_all_after_without_preload_content( + self, + cancel_all_after_request: CancelAllAfterRequest, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Set cancel-all-after countdown + + Arm, refresh, or disarm an account-scoped dead-man's-switch countdown. While armed, all open orders for `accountId` (same scope as `POST /v2/cancelAll` with no `symbol` filter) are mass-cancelled if the countdown is not refreshed with another call before `timeoutMs` elapses. `timeoutMs: 0` disarms; non-zero values must be within [5000, 60000] milliseconds. Refresh is explicit only — order entry, WebSocket ping/pong, and connection close neither refresh nor trigger the countdown. `accountId`, `timeoutMs`, and `nonce` are bound into the EIP-712 `CancelAllAfter` signature; `deadline` is the signature-validity window (Unix seconds), not the countdown trigger time. The response returns the effective trigger timestamp (`triggerAt`, milliseconds; omitted when disarming). Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `cancelAllAfter`). + + :param cancel_all_after_request: (required) + :type cancel_all_after_request: CancelAllAfterRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._cancel_all_after_serialize( + cancel_all_after_request=cancel_all_after_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "CancelAllAfterResponse", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _cancel_all_after_serialize( + self, + cancel_all_after_request, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + if cancel_all_after_request is not None: + _body_params = cancel_all_after_request + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/cancelAllAfter', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def cancel_order( self, @@ -340,7 +623,7 @@ async def cancel_order( ) -> CancelOrderResponse: """Cancel order - Cancel an existing order. Supports both spot and perp markets. All single cancels — spot and perp, including TP/SL — route through the matching engine on a unified `marketId` namespace. `accountId`, `nonce`, and `deadline` are required and bound into the EIP-712 signature. + Cancel an existing order. Supports both spot and perp markets. All single cancels — spot and perp, including TP/SL — route through the matching engine on a unified `marketId` namespace. `accountId`, `nonce`, and `deadline` are required and bound into the EIP-712 signature. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `cancelOrder`). :param cancel_order_request: (required) :type cancel_order_request: CancelOrderRequest @@ -409,7 +692,7 @@ async def cancel_order_with_http_info( ) -> ApiResponse[CancelOrderResponse]: """Cancel order - Cancel an existing order. Supports both spot and perp markets. All single cancels — spot and perp, including TP/SL — route through the matching engine on a unified `marketId` namespace. `accountId`, `nonce`, and `deadline` are required and bound into the EIP-712 signature. + Cancel an existing order. Supports both spot and perp markets. All single cancels — spot and perp, including TP/SL — route through the matching engine on a unified `marketId` namespace. `accountId`, `nonce`, and `deadline` are required and bound into the EIP-712 signature. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `cancelOrder`). :param cancel_order_request: (required) :type cancel_order_request: CancelOrderRequest @@ -478,7 +761,7 @@ async def cancel_order_without_preload_content( ) -> RESTResponseType: """Cancel order - Cancel an existing order. Supports both spot and perp markets. All single cancels — spot and perp, including TP/SL — route through the matching engine on a unified `marketId` namespace. `accountId`, `nonce`, and `deadline` are required and bound into the EIP-712 signature. + Cancel an existing order. Supports both spot and perp markets. All single cancels — spot and perp, including TP/SL — route through the matching engine on a unified `marketId` namespace. `accountId`, `nonce`, and `deadline` are required and bound into the EIP-712 signature. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `cancelOrder`). :param cancel_order_request: (required) :type cancel_order_request: CancelOrderRequest @@ -619,7 +902,7 @@ async def create_order( ) -> CreateOrderResponse: """Create order - Create a new order (IOC, GTC, SL, TP). Supports both spot and perp markets. For perp markets, routing between the matching engine and the conditional-order path is determined server-side by `orderType` (LIMIT/TP/SL) and `timeInForce` (IOC/GTC). NOTE: this routing will be revisited once SL/TP orders land as native types in the matching engine / reya-chain. + Create a new order (IOC, GTC, GTT, SL, TP). Supports both spot and perp markets. For perp markets, routing between the matching engine and the conditional-order path is determined server-side by `orderType` (LIMIT/TP/SL) and `timeInForce` (IOC/GTC). NOTE: this routing will be revisited once SL/TP orders land as native types in the matching engine / reya-chain. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `createOrder`). :param create_order_request: (required) :type create_order_request: CreateOrderRequest @@ -688,7 +971,7 @@ async def create_order_with_http_info( ) -> ApiResponse[CreateOrderResponse]: """Create order - Create a new order (IOC, GTC, SL, TP). Supports both spot and perp markets. For perp markets, routing between the matching engine and the conditional-order path is determined server-side by `orderType` (LIMIT/TP/SL) and `timeInForce` (IOC/GTC). NOTE: this routing will be revisited once SL/TP orders land as native types in the matching engine / reya-chain. + Create a new order (IOC, GTC, GTT, SL, TP). Supports both spot and perp markets. For perp markets, routing between the matching engine and the conditional-order path is determined server-side by `orderType` (LIMIT/TP/SL) and `timeInForce` (IOC/GTC). NOTE: this routing will be revisited once SL/TP orders land as native types in the matching engine / reya-chain. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `createOrder`). :param create_order_request: (required) :type create_order_request: CreateOrderRequest @@ -757,7 +1040,7 @@ async def create_order_without_preload_content( ) -> RESTResponseType: """Create order - Create a new order (IOC, GTC, SL, TP). Supports both spot and perp markets. For perp markets, routing between the matching engine and the conditional-order path is determined server-side by `orderType` (LIMIT/TP/SL) and `timeInForce` (IOC/GTC). NOTE: this routing will be revisited once SL/TP orders land as native types in the matching engine / reya-chain. + Create a new order (IOC, GTC, GTT, SL, TP). Supports both spot and perp markets. For perp markets, routing between the matching engine and the conditional-order path is determined server-side by `orderType` (LIMIT/TP/SL) and `timeInForce` (IOC/GTC). NOTE: this routing will be revisited once SL/TP orders land as native types in the matching engine / reya-chain. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `createOrder`). :param create_order_request: (required) :type create_order_request: CreateOrderRequest @@ -877,3 +1160,282 @@ def _create_order_serialize( ) + + + @validate_call + async def modify_order( + self, + modify_order_request: ModifyOrderRequest, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ModifyOrderResponse: + """Modify order + + Modify a resting `LIMIT` order in place. The order keeps its `orderId` and `clientOrderId`. Target exactly one of `orderId` / `clientOrderId`. **Full restate:** the body carries the same fields as `createOrder` (the complete post-modify `OrderDetails`) plus the target id — restate every value, even unchanged ones; there is no omitted-means-inherited shorthand. The four modifiable fields are `limitPx`, `qty`, `postOnly`, `expiresAfter`; the rest (`exchangeId`, `isBuy`, `orderType`, `triggerPx`, `timeInForce`, `reduceOnly`, `clientOrderId`, `accountId`, `signerWallet`) are immutable and must be restated at the resting order's values — a mismatch is rejected with `MODIFY_IMMUTABLE_MISMATCH`. A request whose post-modify state equals the order's current state is rejected with `EMPTY_MODIFY_ERROR`. Queue priority: a `qty` decrease at an unchanged `limitPx` preserves the order's place in the book; any `limitPx` change or `qty` increase loses it. `timeInForce` is immutable (no GTC↔GTT). A post-only modification that would cross is rejected with no mutation; a non-post-only modification that crosses executes — the response reports `execQty` and `status`, with per-fill detail on the streaming surface, as with `createOrder`. The fresh EIP-712 signature (same `Order` / `OrderDetails` envelope as `createOrder`) is verified over exactly the restated fields; a fresh `nonce` is required. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `modifyOrder`). + + :param modify_order_request: (required) + :type modify_order_request: ModifyOrderRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._modify_order_serialize( + modify_order_request=modify_order_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ModifyOrderResponse", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def modify_order_with_http_info( + self, + modify_order_request: ModifyOrderRequest, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[ModifyOrderResponse]: + """Modify order + + Modify a resting `LIMIT` order in place. The order keeps its `orderId` and `clientOrderId`. Target exactly one of `orderId` / `clientOrderId`. **Full restate:** the body carries the same fields as `createOrder` (the complete post-modify `OrderDetails`) plus the target id — restate every value, even unchanged ones; there is no omitted-means-inherited shorthand. The four modifiable fields are `limitPx`, `qty`, `postOnly`, `expiresAfter`; the rest (`exchangeId`, `isBuy`, `orderType`, `triggerPx`, `timeInForce`, `reduceOnly`, `clientOrderId`, `accountId`, `signerWallet`) are immutable and must be restated at the resting order's values — a mismatch is rejected with `MODIFY_IMMUTABLE_MISMATCH`. A request whose post-modify state equals the order's current state is rejected with `EMPTY_MODIFY_ERROR`. Queue priority: a `qty` decrease at an unchanged `limitPx` preserves the order's place in the book; any `limitPx` change or `qty` increase loses it. `timeInForce` is immutable (no GTC↔GTT). A post-only modification that would cross is rejected with no mutation; a non-post-only modification that crosses executes — the response reports `execQty` and `status`, with per-fill detail on the streaming surface, as with `createOrder`. The fresh EIP-712 signature (same `Order` / `OrderDetails` envelope as `createOrder`) is verified over exactly the restated fields; a fresh `nonce` is required. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `modifyOrder`). + + :param modify_order_request: (required) + :type modify_order_request: ModifyOrderRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._modify_order_serialize( + modify_order_request=modify_order_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ModifyOrderResponse", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def modify_order_without_preload_content( + self, + modify_order_request: ModifyOrderRequest, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Modify order + + Modify a resting `LIMIT` order in place. The order keeps its `orderId` and `clientOrderId`. Target exactly one of `orderId` / `clientOrderId`. **Full restate:** the body carries the same fields as `createOrder` (the complete post-modify `OrderDetails`) plus the target id — restate every value, even unchanged ones; there is no omitted-means-inherited shorthand. The four modifiable fields are `limitPx`, `qty`, `postOnly`, `expiresAfter`; the rest (`exchangeId`, `isBuy`, `orderType`, `triggerPx`, `timeInForce`, `reduceOnly`, `clientOrderId`, `accountId`, `signerWallet`) are immutable and must be restated at the resting order's values — a mismatch is rejected with `MODIFY_IMMUTABLE_MISMATCH`. A request whose post-modify state equals the order's current state is rejected with `EMPTY_MODIFY_ERROR`. Queue priority: a `qty` decrease at an unchanged `limitPx` preserves the order's place in the book; any `limitPx` change or `qty` increase loses it. `timeInForce` is immutable (no GTC↔GTT). A post-only modification that would cross is rejected with no mutation; a non-post-only modification that crosses executes — the response reports `execQty` and `status`, with per-fill detail on the streaming surface, as with `createOrder`. The fresh EIP-712 signature (same `Order` / `OrderDetails` envelope as `createOrder`) is verified over exactly the restated fields; a fresh `nonce` is required. Also available on the order-entry WebSocket (`asyncapi-exec-v2.yaml`, `modifyOrder`). + + :param modify_order_request: (required) + :type modify_order_request: ModifyOrderRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._modify_order_serialize( + modify_order_request=modify_order_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "ModifyOrderResponse", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _modify_order_serialize( + self, + modify_order_request, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + if modify_order_request is not None: + _body_params = modify_order_request + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/modifyOrder', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/sdk/open_api/api/reference_data_api.py b/sdk/open_api/api/reference_data_api.py index bad18000..969ce4b9 100644 --- a/sdk/open_api/api/reference_data_api.py +++ b/sdk/open_api/api/reference_data_api.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -1034,260 +1034,6 @@ def _get_liquidity_parameters_serialize( - @validate_call - async def get_market_definitions( - self, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> List[MarketDefinition]: - """(Deprecated) Get market definitions - - Deprecated: use `/perpMarketDefinitions` instead. This un-prefixed route still works but will be removed once integrators have migrated to the `perp*` naming. - - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - warnings.warn("GET /marketDefinitions is deprecated.", DeprecationWarning) - - _param = self._get_market_definitions_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "List[MarketDefinition]", - '400': "RequestError", - '500': "ServerError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - await response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ).data - - - @validate_call - async def get_market_definitions_with_http_info( - self, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[List[MarketDefinition]]: - """(Deprecated) Get market definitions - - Deprecated: use `/perpMarketDefinitions` instead. This un-prefixed route still works but will be removed once integrators have migrated to the `perp*` naming. - - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - warnings.warn("GET /marketDefinitions is deprecated.", DeprecationWarning) - - _param = self._get_market_definitions_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "List[MarketDefinition]", - '400': "RequestError", - '500': "ServerError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - await response_data.read() - return self.api_client.response_deserialize( - response_data=response_data, - response_types_map=_response_types_map, - ) - - - @validate_call - async def get_market_definitions_without_preload_content( - self, - _request_timeout: Union[ - None, - Annotated[StrictFloat, Field(gt=0)], - Tuple[ - Annotated[StrictFloat, Field(gt=0)], - Annotated[StrictFloat, Field(gt=0)] - ] - ] = None, - _request_auth: Optional[Dict[StrictStr, Any]] = None, - _content_type: Optional[StrictStr] = None, - _headers: Optional[Dict[StrictStr, Any]] = None, - _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> RESTResponseType: - """(Deprecated) Get market definitions - - Deprecated: use `/perpMarketDefinitions` instead. This un-prefixed route still works but will be removed once integrators have migrated to the `perp*` naming. - - :param _request_timeout: timeout setting for this request. If one - number provided, it will be total request - timeout. It can also be a pair (tuple) of - (connection, read) timeouts. - :type _request_timeout: int, tuple(int, int), optional - :param _request_auth: set to override the auth_settings for an a single - request; this effectively ignores the - authentication in the spec for a single request. - :type _request_auth: dict, optional - :param _content_type: force content-type for the request. - :type _content_type: str, Optional - :param _headers: set to override the headers for a single - request; this effectively ignores the headers - in the spec for a single request. - :type _headers: dict, optional - :param _host_index: set to override the host_index for a single - request; this effectively ignores the host_index - in the spec for a single request. - :type _host_index: int, optional - :return: Returns the result object. - """ # noqa: E501 - warnings.warn("GET /marketDefinitions is deprecated.", DeprecationWarning) - - _param = self._get_market_definitions_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "List[MarketDefinition]", - '400': "RequestError", - '500': "ServerError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _get_market_definitions_serialize( - self, - _request_auth, - _content_type, - _headers, - _host_index, - ) -> RequestSerialized: - - _host = None - - _collection_formats: Dict[str, str] = { - } - - _path_params: Dict[str, str] = {} - _query_params: List[Tuple[str, str]] = [] - _header_params: Dict[str, Optional[str]] = _headers or {} - _form_params: List[Tuple[str, str]] = [] - _files: Dict[ - str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] - ] = {} - _body_params: Optional[bytes] = None - - # process the path parameters - # process the query parameters - # process the header parameters - # process the form parameters - # process the body parameter - - - # set the HTTP header `Accept` - if 'Accept' not in _header_params: - _header_params['Accept'] = self.api_client.select_header_accept( - [ - 'application/json' - ] - ) - - - # authentication setting - _auth_settings: List[str] = [ - ] - - return self.api_client.param_serialize( - method='GET', - resource_path='/marketDefinitions', - path_params=_path_params, - query_params=_query_params, - header_params=_header_params, - body=_body_params, - post_params=_form_params, - files=_files, - auth_settings=_auth_settings, - collection_formats=_collection_formats, - _host=_host, - _request_auth=_request_auth - ) - - - - @validate_call async def get_perp_market_definitions( self, diff --git a/sdk/open_api/api/specs_api.py b/sdk/open_api/api/specs_api.py index 0f924a9b..c0ae1fc0 100644 --- a/sdk/open_api/api/specs_api.py +++ b/sdk/open_api/api/specs_api.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/api/wallet_data_api.py b/sdk/open_api/api/wallet_data_api.py index 127f1328..01a9cb90 100644 --- a/sdk/open_api/api/wallet_data_api.py +++ b/sdk/open_api/api/wallet_data_api.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/api_client.py b/sdk/open_api/api_client.py index ed03825a..cdc23338 100644 --- a/sdk/open_api/api_client.py +++ b/sdk/open_api/api_client.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -90,7 +90,7 @@ def __init__( self.default_headers[header_name] = header_value self.cookie = cookie # Set default User-Agent. - self.user_agent = 'OpenAPI-Generator/3.0.1.0/python' + self.user_agent = 'OpenAPI-Generator/3.0.5.0/python' self.client_side_validation = configuration.client_side_validation async def __aenter__(self): diff --git a/sdk/open_api/configuration.py b/sdk/open_api/configuration.py index 56f93dd5..6a0140a5 100644 --- a/sdk/open_api/configuration.py +++ b/sdk/open_api/configuration.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -496,8 +496,8 @@ def to_debug_report(self) -> str: return "Python SDK Debug Report:\n"\ "OS: {env}\n"\ "Python Version: {pyversion}\n"\ - "Version of the API: 3.0.1\n"\ - "SDK Package Version: 3.0.1.0".\ + "Version of the API: 3.0.6\n"\ + "SDK Package Version: 3.0.5.0".\ format(env=sys.platform, pyversion=sys.version) def get_host_settings(self) -> List[HostSetting]: diff --git a/sdk/open_api/exceptions.py b/sdk/open_api/exceptions.py index 4fb0233b..d44b711a 100644 --- a/sdk/open_api/exceptions.py +++ b/sdk/open_api/exceptions.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/__init__.py b/sdk/open_api/models/__init__.py index 769315f3..f42f9a71 100644 --- a/sdk/open_api/models/__init__.py +++ b/sdk/open_api/models/__init__.py @@ -6,7 +6,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -18,6 +18,8 @@ from sdk.open_api.models.account_balance import AccountBalance from sdk.open_api.models.account_type import AccountType from sdk.open_api.models.asset_definition import AssetDefinition +from sdk.open_api.models.cancel_all_after_request import CancelAllAfterRequest +from sdk.open_api.models.cancel_all_after_response import CancelAllAfterResponse from sdk.open_api.models.cancel_order_request import CancelOrderRequest from sdk.open_api.models.cancel_order_response import CancelOrderResponse from sdk.open_api.models.candle_history_data import CandleHistoryData @@ -36,6 +38,8 @@ from sdk.open_api.models.market_summary import MarketSummary from sdk.open_api.models.mass_cancel_request import MassCancelRequest from sdk.open_api.models.mass_cancel_response import MassCancelResponse +from sdk.open_api.models.modify_order_request import ModifyOrderRequest +from sdk.open_api.models.modify_order_response import ModifyOrderResponse from sdk.open_api.models.order import Order from sdk.open_api.models.order_status import OrderStatus from sdk.open_api.models.order_type import OrderType diff --git a/sdk/open_api/models/account.py b/sdk/open_api/models/account.py index 51b106c2..3a6e2f96 100644 --- a/sdk/open_api/models/account.py +++ b/sdk/open_api/models/account.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/account_balance.py b/sdk/open_api/models/account_balance.py index 2748b978..f2b081e4 100644 --- a/sdk/open_api/models/account_balance.py +++ b/sdk/open_api/models/account_balance.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/account_type.py b/sdk/open_api/models/account_type.py index efcf8cdc..0e17000e 100644 --- a/sdk/open_api/models/account_type.py +++ b/sdk/open_api/models/account_type.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/asset_definition.py b/sdk/open_api/models/asset_definition.py index 941506d8..f25aed07 100644 --- a/sdk/open_api/models/asset_definition.py +++ b/sdk/open_api/models/asset_definition.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/cancel_all_after_request.py b/sdk/open_api/models/cancel_all_after_request.py new file mode 100644 index 00000000..357293bc --- /dev/null +++ b/sdk/open_api/models/cancel_all_after_request.py @@ -0,0 +1,118 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 3.0.6 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class CancelAllAfterRequest(BaseModel): + """ + Arms, refreshes, or disarms the account-scoped cancel-all-after countdown (dead-man's-switch / cancel-on-disconnect). While armed, the server mass-cancels all of the account's open orders (same scope as `POST /v2/cancelAll` with no `symbol` filter) if the countdown is not refreshed with another `cancelAllAfter` call before `timeoutMs` elapses. Refresh is explicit only: order-entry traffic, WebSocket protocol pings, and app-level `ping`/`pong` frames do NOT refresh the countdown, and closing a WebSocket connection does NOT trigger it — only countdown expiry does. The switch is transport-agnostic (arm over REST, refresh over WS, or vice versa) and survives reconnects until it fires or is disarmed. `accountId`, `timeoutMs`, and `nonce` are signed via EIP-712 into the `CancelAllAfter(uint64 verifyingChainId, uint64 deadline, CancelAllAfterDetails cancelAllAfter)` envelope, where `CancelAllAfterDetails(uint64 accountId, uint64 timeoutMs, uint64 nonce)` and `deadline` is the signature validity. See `docs/eip712.md` for the signing algorithm and exact typehash strings. + """ # noqa: E501 + account_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="accountId") + timeout_ms: Annotated[int, Field(strict=True, ge=0)] = Field(alias="timeoutMs") + signature: StrictStr = Field(description="EIP-712 signature over the `CancelAllAfter(uint64 verifyingChainId, uint64 deadline, CancelAllAfterDetails cancelAllAfter)` envelope, where `CancelAllAfterDetails(uint64 accountId, uint64 timeoutMs, uint64 nonce)`. See `docs/eip712.md` for the exact typehash string and signing algorithm.") + nonce: StrictStr = Field(description="Monotonically increasing per-signer nonce. A fresh nonce is required on every arm/refresh/disarm call; replayed nonces are rejected with `INVALID_NONCE_ERROR`.") + signer_wallet: Annotated[str, Field(strict=True)] = Field(alias="signerWallet") + deadline: Annotated[int, Field(strict=True, ge=0)] + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["accountId", "timeoutMs", "signature", "nonce", "signerWallet", "deadline"] + + @field_validator('signer_wallet') + def signer_wallet_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not re.match(r"^0x[a-fA-F0-9]{40}$", value): + raise ValueError(r"must validate the regular expression /^0x[a-fA-F0-9]{40}$/") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of CancelAllAfterRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of CancelAllAfterRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "accountId": obj.get("accountId"), + "timeoutMs": obj.get("timeoutMs"), + "signature": obj.get("signature"), + "nonce": obj.get("nonce"), + "signerWallet": obj.get("signerWallet"), + "deadline": obj.get("deadline") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdk/open_api/models/cancel_all_after_response.py b/sdk/open_api/models/cancel_all_after_response.py new file mode 100644 index 00000000..12fa216f --- /dev/null +++ b/sdk/open_api/models/cancel_all_after_response.py @@ -0,0 +1,105 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 3.0.6 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class CancelAllAfterResponse(BaseModel): + """ + CancelAllAfterResponse + """ # noqa: E501 + account_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="accountId") + timeout_ms: Annotated[int, Field(strict=True, ge=0)] = Field(alias="timeoutMs") + trigger_at: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="triggerAt") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["accountId", "timeoutMs", "triggerAt"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of CancelAllAfterResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of CancelAllAfterResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "accountId": obj.get("accountId"), + "timeoutMs": obj.get("timeoutMs"), + "triggerAt": obj.get("triggerAt") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdk/open_api/models/cancel_order_request.py b/sdk/open_api/models/cancel_order_request.py index 8ac4f6d8..51e4ef84 100644 --- a/sdk/open_api/models/cancel_order_request.py +++ b/sdk/open_api/models/cancel_order_request.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/cancel_order_response.py b/sdk/open_api/models/cancel_order_response.py index dc24afac..d3333b77 100644 --- a/sdk/open_api/models/cancel_order_response.py +++ b/sdk/open_api/models/cancel_order_response.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/candle_history_data.py b/sdk/open_api/models/candle_history_data.py index e792c7d3..50b897ef 100644 --- a/sdk/open_api/models/candle_history_data.py +++ b/sdk/open_api/models/candle_history_data.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/create_order_request.py b/sdk/open_api/models/create_order_request.py index 84e34103..9a1de055 100644 --- a/sdk/open_api/models/create_order_request.py +++ b/sdk/open_api/models/create_order_request.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -39,7 +39,7 @@ class CreateOrderRequest(BaseModel): time_in_force: Optional[TimeInForce] = Field(default=None, alias="timeInForce") trigger_px: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="triggerPx") reduce_only: Optional[StrictBool] = Field(default=None, description="Reduce-only intent. Perp only; spot markets must set this to false. Maps to on-chain `OrderDetails.reduceOnly`.", alias="reduceOnly") - post_only: Optional[StrictBool] = Field(default=None, description="Post-only (maker-only) intent: the order must rest and never cross as a taker. Valid on GTC/GTT; rejected on IOC. Maps to on-chain `OrderDetails.postOnly`.", alias="postOnly") + post_only: Optional[StrictBool] = Field(default=None, description="Post-only (maker-only) intent: the order must rest and never cross as a taker. Valid on GTC/GTT; rejected on IOC. An order that would cross at insertion is rejected with `POST_ONLY_WOULD_CROSS_ERROR`. Maps to on-chain `OrderDetails.postOnly`.", alias="postOnly") signature: StrictStr = Field(description="EIP-712 signature over the `Order(uint256 verifyingChainId, uint256 deadline, OrderDetails order)` envelope. See `docs/eip712.md` for the exact typehash string and signing algorithm.") nonce: StrictStr = Field(description="Monotonically increasing per-signer nonce. Maps to on-chain `OrderDetails.nonce`.") signer_wallet: Annotated[str, Field(strict=True)] = Field(alias="signerWallet") diff --git a/sdk/open_api/models/create_order_response.py b/sdk/open_api/models/create_order_response.py index f9df0795..fe3f4c67 100644 --- a/sdk/open_api/models/create_order_response.py +++ b/sdk/open_api/models/create_order_response.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/depth.py b/sdk/open_api/models/depth.py index 99427398..15954ff8 100644 --- a/sdk/open_api/models/depth.py +++ b/sdk/open_api/models/depth.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/depth_type.py b/sdk/open_api/models/depth_type.py index 9d9ce021..63da958e 100644 --- a/sdk/open_api/models/depth_type.py +++ b/sdk/open_api/models/depth_type.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/execution_bust.py b/sdk/open_api/models/execution_bust.py index e8c14ac3..d6ae5a16 100644 --- a/sdk/open_api/models/execution_bust.py +++ b/sdk/open_api/models/execution_bust.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/execution_bust_list.py b/sdk/open_api/models/execution_bust_list.py index 9ac9569f..ac31b097 100644 --- a/sdk/open_api/models/execution_bust_list.py +++ b/sdk/open_api/models/execution_bust_list.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/execution_type.py b/sdk/open_api/models/execution_type.py index 474942ac..140e19dc 100644 --- a/sdk/open_api/models/execution_type.py +++ b/sdk/open_api/models/execution_type.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/fee_tier_parameters.py b/sdk/open_api/models/fee_tier_parameters.py index 30b104d2..61694e07 100644 --- a/sdk/open_api/models/fee_tier_parameters.py +++ b/sdk/open_api/models/fee_tier_parameters.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/global_fee_parameters.py b/sdk/open_api/models/global_fee_parameters.py index afbc4af8..7374b922 100644 --- a/sdk/open_api/models/global_fee_parameters.py +++ b/sdk/open_api/models/global_fee_parameters.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/level.py b/sdk/open_api/models/level.py index c2d7df6e..114e3b9b 100644 --- a/sdk/open_api/models/level.py +++ b/sdk/open_api/models/level.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/liquidity_parameters.py b/sdk/open_api/models/liquidity_parameters.py index 14567203..943d0974 100644 --- a/sdk/open_api/models/liquidity_parameters.py +++ b/sdk/open_api/models/liquidity_parameters.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/market_definition.py b/sdk/open_api/models/market_definition.py index a974406e..6ce722b5 100644 --- a/sdk/open_api/models/market_definition.py +++ b/sdk/open_api/models/market_definition.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/market_summary.py b/sdk/open_api/models/market_summary.py index 6d0eaca7..de2e3950 100644 --- a/sdk/open_api/models/market_summary.py +++ b/sdk/open_api/models/market_summary.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/mass_cancel_request.py b/sdk/open_api/models/mass_cancel_request.py index 341ed63f..92310960 100644 --- a/sdk/open_api/models/mass_cancel_request.py +++ b/sdk/open_api/models/mass_cancel_request.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/mass_cancel_response.py b/sdk/open_api/models/mass_cancel_response.py index 288df8f6..d4ac6a14 100644 --- a/sdk/open_api/models/mass_cancel_response.py +++ b/sdk/open_api/models/mass_cancel_response.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/modify_order_request.py b/sdk/open_api/models/modify_order_request.py new file mode 100644 index 00000000..ca089860 --- /dev/null +++ b/sdk/open_api/models/modify_order_request.py @@ -0,0 +1,175 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 3.0.6 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.time_in_force import TimeInForce +from typing import Optional, Set +from typing_extensions import Self + +class ModifyOrderRequest(BaseModel): + """ + Modifies a resting `LIMIT` order in place. The order keeps its `orderId` and `clientOrderId`. Target exactly one of `orderId` / `clientOrderId`; supplying both or neither is rejected with `INPUT_VALIDATION_ERROR`, and a target that does not resolve to a live resting order is rejected with `ORDER_NOT_FOUND`. **Full restate:** the request carries the SAME fields as `createOrder` (the complete post-modify `OrderDetails`) plus the target id — restate every value at its post-modify state, even unchanged ones; there is no omitted-means-inherited shorthand. The four modifiable fields are `limitPx`, `qty`, `postOnly`, `expiresAfter`. The remaining `OrderDetails` fields (`exchangeId`, `isBuy`/side, `orderType`, `triggerPx`, `timeInForce`, `reduceOnly`, `clientOrderId`, `accountId`, `signerWallet`) are IMMUTABLE: restate them at the resting order's values — the matching engine verifies each equals the resting order and rejects a mismatch with `MODIFY_IMMUTABLE_MISMATCH`. The EIP-712 signature is verified over exactly the restated fields (the same `Order` / `OrderDetails` envelope as `createOrder`), so it needs no resting-order lookup; a fresh `nonce` is required. A request whose post-modify state is identical to the order's current state is rejected with `EMPTY_MODIFY_ERROR`. Queue priority: a `qty` decrease at an unchanged `limitPx` preserves the order's place in the book; any `limitPx` change or `qty` increase loses it (re-queued at the new level). `qty` is the TOTAL order quantity, not remaining, and must be strictly greater than the order's `cumQty` (else `MODIFY_QTY_BELOW_FILLED_ERROR`). `timeInForce` is immutable — a modify cannot flip GTC↔GTT; the restated `timeInForce` must match the resting order and `expiresAfter` must stay consistent with it (`0` for GTC, a non-zero future timestamp for GTT). If the post-modify order is post-only and would cross, the modification is rejected with `POST_ONLY_WOULD_CROSS_ERROR` and the resting order is left untouched, priority intact. If it is not post-only and the new `limitPx` crosses, the modification executes immediately — the response reports the executed quantity (`execQty`) and resulting `status`, with per-fill detail delivered on the wallet executions and `walletOrderChanges` streams, exactly as for `createOrder`. See `docs/eip712.md` for the signing algorithm and exact typehash strings. + """ # noqa: E501 + order_id: Optional[StrictStr] = Field(default=None, description="Internal matching engine order ID of the order to modify. Exactly one of `orderId` or `clientOrderId` must be provided.", alias="orderId") + client_order_id: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="clientOrderId") + symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") + account_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="accountId") + exchange_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="exchangeId") + is_buy: StrictBool = Field(description="Order side. Immutable — restate the resting order's value. Combined with `qty`, sets the signed `OrderDetails.quantity` (int256). A mismatch is rejected with `MODIFY_IMMUTABLE_MISMATCH`.", alias="isBuy") + order_type: OrderType = Field(alias="orderType") + time_in_force: Optional[TimeInForce] = Field(default=None, alias="timeInForce") + trigger_px: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="triggerPx") + reduce_only: Optional[StrictBool] = Field(default=None, description="On-chain `OrderDetails.reduceOnly`. Immutable — restate the resting order's value. A mismatch is rejected with `MODIFY_IMMUTABLE_MISMATCH`.", alias="reduceOnly") + limit_px: Annotated[str, Field(strict=True)] = Field(alias="limitPx") + qty: Annotated[str, Field(strict=True)] + post_only: StrictBool = Field(description="The post-modify post-only (maker-only) flag. Always required — send the complete intended value even when it is unchanged from the resting order. If true and the post-modify order would cross, the modification is rejected with `POST_ONLY_WOULD_CROSS_ERROR` and the resting order is unchanged.", alias="postOnly") + expires_after: Annotated[int, Field(strict=True, ge=0)] = Field(alias="expiresAfter") + signature: StrictStr = Field(description="Fresh EIP-712 signature over the full post-modify order state — the same `Order` envelope as `createOrder`, with the modified values substituted into `OrderDetails`. See `docs/eip712.md` for the exact typehash string and signing algorithm.") + nonce: StrictStr = Field(description="Monotonically increasing per-signer nonce. A fresh nonce is required for every modification; replayed nonces are rejected with `INVALID_NONCE_ERROR`.") + signer_wallet: Annotated[str, Field(strict=True)] = Field(alias="signerWallet") + deadline: Annotated[int, Field(strict=True, ge=0)] + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["orderId", "clientOrderId", "symbol", "accountId", "exchangeId", "isBuy", "orderType", "timeInForce", "triggerPx", "reduceOnly", "limitPx", "qty", "postOnly", "expiresAfter", "signature", "nonce", "signerWallet", "deadline"] + + @field_validator('symbol') + def symbol_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not re.match(r"^[A-Za-z0-9]+$", value): + raise ValueError(r"must validate the regular expression /^[A-Za-z0-9]+$/") + return value + + @field_validator('trigger_px') + def trigger_px_validate_regular_expression(cls, value): + """Validates the regular expression""" + if value is None: + return value + + if not re.match(r"^-?\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value + + @field_validator('limit_px') + def limit_px_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not re.match(r"^-?\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value + + @field_validator('qty') + def qty_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not re.match(r"^\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value + + @field_validator('signer_wallet') + def signer_wallet_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not re.match(r"^0x[a-fA-F0-9]{40}$", value): + raise ValueError(r"must validate the regular expression /^0x[a-fA-F0-9]{40}$/") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ModifyOrderRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ModifyOrderRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "orderId": obj.get("orderId"), + "clientOrderId": obj.get("clientOrderId"), + "symbol": obj.get("symbol"), + "accountId": obj.get("accountId"), + "exchangeId": obj.get("exchangeId"), + "isBuy": obj.get("isBuy"), + "orderType": obj.get("orderType"), + "timeInForce": obj.get("timeInForce"), + "triggerPx": obj.get("triggerPx"), + "reduceOnly": obj.get("reduceOnly"), + "limitPx": obj.get("limitPx"), + "qty": obj.get("qty"), + "postOnly": obj.get("postOnly"), + "expiresAfter": obj.get("expiresAfter"), + "signature": obj.get("signature"), + "nonce": obj.get("nonce"), + "signerWallet": obj.get("signerWallet"), + "deadline": obj.get("deadline") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdk/open_api/models/modify_order_response.py b/sdk/open_api/models/modify_order_response.py new file mode 100644 index 00000000..1889c6de --- /dev/null +++ b/sdk/open_api/models/modify_order_response.py @@ -0,0 +1,130 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 3.0.6 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from sdk.open_api.models.order_status import OrderStatus +from typing import Optional, Set +from typing_extensions import Self + +class ModifyOrderResponse(BaseModel): + """ + Result of a modification, same shape as `CreateOrderResponse`. `orderId` is always the same ID the order had before the modification. If the modification crossed the book it executed immediately: `execQty` carries the quantity it filled and `status` reflects the outcome (`OPEN` for a partial fill leaving a remainder resting, `FILLED` for a complete fill). Per-fill detail (prices, fees) is delivered on the wallet executions and `walletOrderChanges` streams, exactly as for `createOrder`. + """ # noqa: E501 + status: OrderStatus + exec_qty: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="execQty") + cum_qty: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="cumQty") + order_id: StrictStr = Field(description="Modified order ID — unchanged by the modification.", alias="orderId") + client_order_id: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="clientOrderId") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["status", "execQty", "cumQty", "orderId", "clientOrderId"] + + @field_validator('exec_qty') + def exec_qty_validate_regular_expression(cls, value): + """Validates the regular expression""" + if value is None: + return value + + if not re.match(r"^\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value + + @field_validator('cum_qty') + def cum_qty_validate_regular_expression(cls, value): + """Validates the regular expression""" + if value is None: + return value + + if not re.match(r"^\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ModifyOrderResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ModifyOrderResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "status": obj.get("status"), + "execQty": obj.get("execQty"), + "cumQty": obj.get("cumQty"), + "orderId": obj.get("orderId"), + "clientOrderId": obj.get("clientOrderId") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdk/open_api/models/order.py b/sdk/open_api/models/order.py index e80aac6e..13bca069 100644 --- a/sdk/open_api/models/order.py +++ b/sdk/open_api/models/order.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -43,12 +43,14 @@ class Order(BaseModel): order_type: OrderType = Field(alias="orderType") trigger_px: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="triggerPx") time_in_force: Optional[TimeInForce] = Field(default=None, alias="timeInForce") + expires_after: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="expiresAfter") reduce_only: Optional[StrictBool] = Field(default=None, description="Whether this is a reduce-only order, exclusively used for LIMIT IOC orders.", alias="reduceOnly") + post_only: Optional[StrictBool] = Field(default=None, description="Whether this is a post-only (maker-only) order. Mirrors `CreateOrderRequest.postOnly`; updated by `modifyOrder`.", alias="postOnly") status: OrderStatus created_at: Annotated[int, Field(strict=True, ge=0)] = Field(alias="createdAt") last_update_at: Annotated[int, Field(strict=True, ge=0)] = Field(alias="lastUpdateAt") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "accountId", "orderId", "qty", "execQty", "cumQty", "side", "limitPx", "orderType", "triggerPx", "timeInForce", "reduceOnly", "status", "createdAt", "lastUpdateAt"] + __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "accountId", "orderId", "qty", "execQty", "cumQty", "side", "limitPx", "orderType", "triggerPx", "timeInForce", "expiresAfter", "reduceOnly", "postOnly", "status", "createdAt", "lastUpdateAt"] @field_validator('symbol') def symbol_validate_regular_expression(cls, value): @@ -174,7 +176,9 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "orderType": obj.get("orderType"), "triggerPx": obj.get("triggerPx"), "timeInForce": obj.get("timeInForce"), + "expiresAfter": obj.get("expiresAfter"), "reduceOnly": obj.get("reduceOnly"), + "postOnly": obj.get("postOnly"), "status": obj.get("status"), "createdAt": obj.get("createdAt"), "lastUpdateAt": obj.get("lastUpdateAt") diff --git a/sdk/open_api/models/order_status.py b/sdk/open_api/models/order_status.py index c128aecf..52fc596e 100644 --- a/sdk/open_api/models/order_status.py +++ b/sdk/open_api/models/order_status.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/order_type.py b/sdk/open_api/models/order_type.py index ce413c3f..a5f1d542 100644 --- a/sdk/open_api/models/order_type.py +++ b/sdk/open_api/models/order_type.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/pagination_meta.py b/sdk/open_api/models/pagination_meta.py index 312faca7..571fbf81 100644 --- a/sdk/open_api/models/pagination_meta.py +++ b/sdk/open_api/models/pagination_meta.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/perp_execution.py b/sdk/open_api/models/perp_execution.py index 66ad5354..d4bd891c 100644 --- a/sdk/open_api/models/perp_execution.py +++ b/sdk/open_api/models/perp_execution.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/perp_execution_list.py b/sdk/open_api/models/perp_execution_list.py index 58de3058..26305a3f 100644 --- a/sdk/open_api/models/perp_execution_list.py +++ b/sdk/open_api/models/perp_execution_list.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/position.py b/sdk/open_api/models/position.py index e02d0c1d..5666f46e 100644 --- a/sdk/open_api/models/position.py +++ b/sdk/open_api/models/position.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/price.py b/sdk/open_api/models/price.py index 8f9409db..dc558570 100644 --- a/sdk/open_api/models/price.py +++ b/sdk/open_api/models/price.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/request_error.py b/sdk/open_api/models/request_error.py index e9ab8609..3b7feb09 100644 --- a/sdk/open_api/models/request_error.py +++ b/sdk/open_api/models/request_error.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/request_error_code.py b/sdk/open_api/models/request_error_code.py index e9b4c345..87db92bc 100644 --- a/sdk/open_api/models/request_error_code.py +++ b/sdk/open_api/models/request_error_code.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -38,6 +38,12 @@ class RequestErrorCode(str, Enum): UNAVAILABLE_MATCHING_ENGINE_ERROR = 'UNAVAILABLE_MATCHING_ENGINE_ERROR' UNAUTHORIZED_SIGNATURE_ERROR = 'UNAUTHORIZED_SIGNATURE_ERROR' NUMERIC_OVERFLOW_ERROR = 'NUMERIC_OVERFLOW_ERROR' + CANCEL_ALL_AFTER_OTHER_ERROR = 'CANCEL_ALL_AFTER_OTHER_ERROR' + ORDER_NOT_FOUND = 'ORDER_NOT_FOUND' + POST_ONLY_WOULD_CROSS_ERROR = 'POST_ONLY_WOULD_CROSS_ERROR' + MODIFY_QTY_BELOW_FILLED_ERROR = 'MODIFY_QTY_BELOW_FILLED_ERROR' + EMPTY_MODIFY_ERROR = 'EMPTY_MODIFY_ERROR' + MODIFY_ORDER_OTHER_ERROR = 'MODIFY_ORDER_OTHER_ERROR' @classmethod def from_json(cls, json_str: str) -> Self: diff --git a/sdk/open_api/models/server_error.py b/sdk/open_api/models/server_error.py index d1f5423d..a311e2c3 100644 --- a/sdk/open_api/models/server_error.py +++ b/sdk/open_api/models/server_error.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/server_error_code.py b/sdk/open_api/models/server_error_code.py index ab339ce0..6a56a4a2 100644 --- a/sdk/open_api/models/server_error_code.py +++ b/sdk/open_api/models/server_error_code.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/side.py b/sdk/open_api/models/side.py index 8854e226..910ef92f 100644 --- a/sdk/open_api/models/side.py +++ b/sdk/open_api/models/side.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/spot_execution.py b/sdk/open_api/models/spot_execution.py index f3ccbb22..108f9e83 100644 --- a/sdk/open_api/models/spot_execution.py +++ b/sdk/open_api/models/spot_execution.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/spot_execution_list.py b/sdk/open_api/models/spot_execution_list.py index b00d1436..7a81eb47 100644 --- a/sdk/open_api/models/spot_execution_list.py +++ b/sdk/open_api/models/spot_execution_list.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/spot_market_definition.py b/sdk/open_api/models/spot_market_definition.py index ab6d6681..9a410ddc 100644 --- a/sdk/open_api/models/spot_market_definition.py +++ b/sdk/open_api/models/spot_market_definition.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/spot_market_summary.py b/sdk/open_api/models/spot_market_summary.py index 11d0bba8..086c1281 100644 --- a/sdk/open_api/models/spot_market_summary.py +++ b/sdk/open_api/models/spot_market_summary.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/tier_type.py b/sdk/open_api/models/tier_type.py index 052c04f3..51ff4db8 100644 --- a/sdk/open_api/models/tier_type.py +++ b/sdk/open_api/models/tier_type.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/time_in_force.py b/sdk/open_api/models/time_in_force.py index 6538fe8d..31062055 100644 --- a/sdk/open_api/models/time_in_force.py +++ b/sdk/open_api/models/time_in_force.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/wallet_configuration.py b/sdk/open_api/models/wallet_configuration.py index ff31e19b..ed0b8cc6 100644 --- a/sdk/open_api/models/wallet_configuration.py +++ b/sdk/open_api/models/wallet_configuration.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/rest.py b/sdk/open_api/rest.py index c91acb5e..e94a9669 100644 --- a/sdk/open_api/rest.py +++ b/sdk/open_api/rest.py @@ -5,7 +5,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 3.0.1 + The version of the OpenAPI document: 3.0.6 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/reya_rest_api/auth/signatures.py b/sdk/reya_rest_api/auth/signatures.py index 09968006..f6dbeaf0 100644 --- a/sdk/reya_rest_api/auth/signatures.py +++ b/sdk/reya_rest_api/auth/signatures.py @@ -2,8 +2,9 @@ Signature generation utilities for Reya Trading API authentication. Implements EIP-712 signing for the unified spot+perp Order envelope, plus -matching-engine-layer OrderCancel and MassCancel envelopes. See -specs/docs/eip712.md for the canonical typehash strings and field semantics. +matching-engine-layer OrderCancel, MassCancel, and CancelAllAfter envelopes. +See specs/docs/eip712.md for the canonical typehash strings and field +semantics. """ from decimal import Decimal @@ -231,6 +232,43 @@ def sign_mass_cancel( signed_message = Account.sign_typed_data(self._private_key, self._domain, types, message) return _to_hex_signature(signed_message.signature.hex()) + def sign_cancel_all_after( + self, + account_id: int, + timeout_ms: int, + nonce: int, + deadline: int, + ) -> str: + """Sign a CancelAllAfter envelope (matching-engine layer). + + Arms/refreshes (`timeout_ms` in [5000, 60000]) or disarms + (`timeout_ms=0`) the account-wide dead-man's-switch countdown.""" + types = { + "CancelAllAfter": [ + {"name": "verifyingChainId", "type": "uint64"}, + {"name": "deadline", "type": "uint64"}, + {"name": "cancelAllAfter", "type": "CancelAllAfterDetails"}, + ], + "CancelAllAfterDetails": [ + {"name": "accountId", "type": "uint64"}, + {"name": "timeoutMs", "type": "uint64"}, + {"name": "nonce", "type": "uint64"}, + ], + } + + message = { + "verifyingChainId": self._chain_id, + "deadline": deadline, + "cancelAllAfter": { + "accountId": account_id, + "timeoutMs": timeout_ms, + "nonce": nonce, + }, + } + + signed_message = Account.sign_typed_data(self._private_key, self._domain, types, message) + return _to_hex_signature(signed_message.signature.hex()) + def _to_hex_signature(sig_hex: str) -> str: """Normalize an eth_account signature hex to a 0x-prefixed string.""" diff --git a/sdk/reya_rest_api/client.py b/sdk/reya_rest_api/client.py index 5d1470c1..8cc7a732 100644 --- a/sdk/reya_rest_api/client.py +++ b/sdk/reya_rest_api/client.py @@ -22,6 +22,8 @@ from sdk.open_api.configuration import Configuration from sdk.open_api.models.account import Account from sdk.open_api.models.account_balance import AccountBalance +from sdk.open_api.models.cancel_all_after_request import CancelAllAfterRequest +from sdk.open_api.models.cancel_all_after_response import CancelAllAfterResponse from sdk.open_api.models.cancel_order_request import CancelOrderRequest from sdk.open_api.models.cancel_order_response import CancelOrderResponse from sdk.open_api.models.create_order_request import CreateOrderRequest @@ -30,6 +32,8 @@ from sdk.open_api.models.market_definition import MarketDefinition from sdk.open_api.models.mass_cancel_request import MassCancelRequest from sdk.open_api.models.mass_cancel_response import MassCancelResponse +from sdk.open_api.models.modify_order_request import ModifyOrderRequest +from sdk.open_api.models.modify_order_response import ModifyOrderResponse from sdk.open_api.models.order import Order from sdk.open_api.models.order_type import OrderType from sdk.open_api.models.perp_execution_list import PerpExecutionList @@ -40,7 +44,7 @@ from sdk.reya_rest_api.auth.signatures import OrderTypeInt, SignatureGenerator, TimeInForceInt from sdk.reya_rest_api.config import TradingConfig, get_config -from .models.orders import LimitOrderParameters, TriggerOrderParameters +from .models.orders import LimitOrderParameters, ModifyOrderParameters, TriggerOrderParameters # Two INDEPENDENT signed time fields: # - `deadline` — EIP-712 signature-validity window, enforced off-chain at @@ -58,6 +62,12 @@ DEFAULT_DEADLINE_S = 60 # signature-validity window (entry only), all order types. PERPETUAL_LIFETIME = 0 # `expiresAfter` sentinel: never expires (GTC rests; IOC moot). +# cancelAllAfter (dead-man's-switch) countdown bounds. `timeoutMs` must be 0 +# (disarm) or within [min, max]; each call replaces the running countdown. +CANCEL_ALL_AFTER_DISARM_MS = 0 +CANCEL_ALL_AFTER_MIN_TIMEOUT_MS = 5_000 +CANCEL_ALL_AFTER_MAX_TIMEOUT_MS = 60_000 + # Spot/perp namespace discriminator on the unified marketId, mirroring the # off-chain market-id namespace convention. Perp market ids are the raw on-chain # core id (well below 1e10); spot market ids are `core_id + 1e10`. Used to gate @@ -72,14 +82,11 @@ OrderType.TAKE_PROFIT: OrderTypeInt.TAKE_PROFIT, } -# Maps the public OpenAPI `TimeInForce` to the signed uint8. The OpenAPI enum now -# exposes GTT, but it's intentionally absent here: GTT entry is gated in -# `build_create_limit_order_payload` (rejected until off-chain support lands), so -# only GTC/IOC reach this map. Add `TimeInForce.GTT: TimeInForceInt.GTT` when the -# gate lifts. +# Maps the public OpenAPI `TimeInForce` to the signed uint8 (0=GTC, 1=IOC, 2=GTT). _TIME_IN_FORCE_TO_INT: dict[TimeInForce, TimeInForceInt] = { TimeInForce.GTC: TimeInForceInt.GTC, TimeInForce.IOC: TimeInForceInt.IOC, + TimeInForce.GTT: TimeInForceInt.GTT, } @@ -134,7 +141,7 @@ async def start(self) -> None: async def _load_market_definitions(self) -> None: """Load both perp and spot market definitions.""" - market_definitions: list[MarketDefinition] = await self.reference.get_market_definitions() + market_definitions: list[MarketDefinition] = await self.reference.get_perp_market_definitions() self._symbol_to_market_id = {market.symbol: market.market_id for market in market_definitions} self._symbol_to_tick_size = {market.symbol: market.tick_size for market in market_definitions} perp_count = len(market_definitions) @@ -247,19 +254,6 @@ def build_create_limit_order_payload(self, params: LimitOrderParameters) -> tupl is_ioc = params.time_in_force == TimeInForce.IOC is_perp_ioc = is_ioc and not is_spot_market - # GTT entry is signing-capable (`TimeInForceInt.GTT`) and now exposed in the - # OpenAPI enum, but it can't travel end-to-end yet: the off-chain still - # reconstructs the 13-field digest, and GTT needs its own `expiresAfter` - # validation (non-zero, and greater than `deadline`). Reject it at entry - # until off-chain support lands, rather than sign an un-settleable order. - # (Lift this together with the `_TIME_IN_FORCE_TO_INT` GTT mapping and the - # GTT expiresAfter rule.) - if params.time_in_force == TimeInForce.GTT: - raise ValueError( - "GTT time-in-force is not yet supported end-to-end (pending off-chain " - "14-field digest reconstruction and GTT expiresAfter validation)" - ) - nonce = self._get_next_nonce() # `deadline` (entry-time signature validity) and `expiresAfter` (on-chain @@ -273,6 +267,18 @@ def build_create_limit_order_payload(self, params: LimitOrderParameters) -> tupl expires_after = params.expires_after if params.expires_after is not None else PERPETUAL_LIFETIME client_order_id = params.client_order_id if params.client_order_id is not None else 0 + # TIF <-> expiresAfter coupling (mirrors the off-chain validator + the ME): + # GTC never expires (expiresAfter must be 0); GTT always expires + # (expiresAfter non-zero and strictly after the deadline). IOC never rests, + # so its lifetime is moot. Fail fast before signing. + if params.time_in_force == TimeInForce.GTT: + if expires_after == 0: + raise ValueError("GTT orders require a non-zero expires_after greater than deadline") + if expires_after <= deadline: + raise ValueError("GTT expires_after must be greater than deadline") + elif params.time_in_force == TimeInForce.GTC and expires_after != 0: + raise ValueError("GTC orders must not expire (expires_after must be 0)") + # `reduceOnly` is accepted by the server ONLY on perp IOC orders; it must # be ABSENT on spot ("not supported for spot markets") and perp GTC # (rejected by the off-chain order validator). So gate the wire field to @@ -285,28 +291,15 @@ def build_create_limit_order_payload(self, params: LimitOrderParameters) -> tupl reduce_only_wire: Optional[bool] = reduce_only if is_perp_ioc else None # `post_only` marks a maker-only order: it must REST, never cross as a - # taker. IOC is immediate-or-cancel (taker by nature), so post_only + IOC - # is self-contradictory (the order could neither take nor rest) and is - # always rejected. On-chain taker-side enforcement is deferred for now; - # this is the entry guard. - # - # Rollout gate: the flag is signed into the 14-field `OrderDetails.postOnly` - # digest and the `CreateOrderRequest` wire model now carries it, but - # `post_only=True` still can't travel end-to-end — the off-chain digest - # reconstruction is still 13-field, so a signed postOnly=true would fail - # signer recovery off-chain. Reject True until off-chain reconstructs 14 - # fields, rather than emit an un-settleable order. The default False is - # unaffected: signed as False and reconstructed as False either way. + # taker. The off-chain verifies the 14-field digest and the matching + # engine enforces would-cross, so post_only=True travels end-to-end. + # IOC is immediate-or-cancel (taker by nature), so post_only + IOC is + # self-contradictory (the order could neither take nor rest) and is + # always rejected. post_only = bool(params.post_only) if params.post_only is not None else False - if post_only: - if is_ioc: - raise ValueError( - "post_only is not supported on IOC orders " - "(IOC is taker-only; post_only requires the order to rest)" - ) + if post_only and is_ioc: raise ValueError( - "post_only=True is not yet supported end-to-end " - "(pending the off-chain 14-field digest reconstruction)" + "post_only is not supported on IOC orders (IOC is taker-only; post_only requires the order to rest)" ) signature = self._signature_generator.sign_order( @@ -337,10 +330,6 @@ def build_create_limit_order_payload(self, params: LimitOrderParameters) -> tupl "orderType": OrderType.LIMIT.value, "timeInForce": params.time_in_force.value if params.time_in_force is not None else None, "reduceOnly": reduce_only_wire, - # Signed into the 14-field digest above and carried on the wire. The - # `CreateOrderRequest` model now has a `postOnly` field, so this is - # transported (no longer dropped); `post_only` is gated to False above - # until the off-chain side reconstructs 14 fields. "postOnly": post_only, "expiresAfter": expires_after, "clientOrderId": params.client_order_id, @@ -598,6 +587,177 @@ def build_mass_cancel_payload( "deadline": deadline, } + async def cancel_all_after( + self, + timeout_ms: int, + account_id: Optional[int] = None, + deadline: Optional[int] = None, + nonce: Optional[int] = None, + ) -> CancelAllAfterResponse: + """ + Arm, refresh, or disarm the account-wide cancel-all-after countdown + (dead-man's-switch). While armed, all of the account's open orders are + mass-cancelled unless the countdown is refreshed with another call + before `timeout_ms` elapses. `timeout_ms=0` disarms; non-zero values + must be within [5000, 60000] ms. Each call replaces the countdown. + Refresh is explicit only — order entry, ping/pong, and connection + close neither refresh nor trigger it. + """ + payload = self.build_cancel_all_after_payload( + timeout_ms=timeout_ms, + account_id=account_id, + deadline=deadline, + nonce=nonce, + ) + return await self.orders.cancel_all_after(CancelAllAfterRequest(**payload)) + + def build_cancel_all_after_payload( + self, + timeout_ms: int, + account_id: Optional[int] = None, + deadline: Optional[int] = None, + nonce: Optional[int] = None, + ) -> dict: + """Build the camelCase wire payload for a cancelAllAfter request. + + Pure (no I/O). Shared by the REST sender above and the ws-exec + transport. Same arg semantics as :meth:`cancel_all_after`. + """ + if timeout_ms != CANCEL_ALL_AFTER_DISARM_MS and not ( + CANCEL_ALL_AFTER_MIN_TIMEOUT_MS <= timeout_ms <= CANCEL_ALL_AFTER_MAX_TIMEOUT_MS + ): + raise ValueError( + f"timeout_ms must be {CANCEL_ALL_AFTER_DISARM_MS} (disarm) or within " + f"[{CANCEL_ALL_AFTER_MIN_TIMEOUT_MS}, {CANCEL_ALL_AFTER_MAX_TIMEOUT_MS}] ms (got {timeout_ms})" + ) + + resolved_account_id = account_id if account_id is not None else self.config.account_id + if resolved_account_id is None: + raise ValueError("account_id is required (pass it or set in config)") + if self._signature_generator is None: + raise ValueError("Signature generator is required for signing") + + resolved_nonce = nonce if nonce is not None else self._get_next_nonce() + resolved_deadline = deadline if deadline is not None else int(time.time()) + DEFAULT_DEADLINE_S + + signature = self._signature_generator.sign_cancel_all_after( + account_id=resolved_account_id, + timeout_ms=timeout_ms, + nonce=resolved_nonce, + deadline=resolved_deadline, + ) + + return { + "accountId": resolved_account_id, + "timeoutMs": timeout_ms, + "signature": signature, + "nonce": str(resolved_nonce), + "signerWallet": self.signer_wallet_address, + "deadline": resolved_deadline, + } + + async def modify_order(self, params: ModifyOrderParameters) -> ModifyOrderResponse: + """ + Modify a resting order in place on either spot or perp markets. The + order keeps its `orderId` and `clientOrderId`. Target exactly one of + `params.order_id` / `params.client_order_id`. The four modifiable + fields (`limit_px`, `qty`, `post_only`, `expires_after`) all carry the + complete post-modify state; the immutables (`is_buy`, `time_in_force`, + `trigger_px`, `reduce_only`, `resting_client_order_id`) must restate + the resting order's values — both go into the fresh EIP-712 signature + over the full post-modify state. + """ + payload, _nonce = self.build_modify_order_payload(params) + return await self.orders.modify_order(ModifyOrderRequest(**payload)) + + def build_modify_order_payload(self, params: ModifyOrderParameters) -> tuple[dict, int]: + """Build the camelCase wire payload for a modifyOrder request and + return ``(payload, nonce)``. + + Pure (no I/O). Shared by the REST sender above and the ws-exec + transport. Signs the same 14-field `OrderDetails` envelope as + createOrder, over the full post-modify order state. + """ + has_order_id = params.order_id is not None + has_client_order_id = params.client_order_id is not None + if has_order_id == has_client_order_id: + raise ValueError("Provide exactly one of order_id or client_order_id") + if has_client_order_id and params.client_order_id == 0: + raise ValueError("client_order_id 0 is not a valid modify target") + if self.config.account_id is None: + raise ValueError("Account ID is required for order signing") + if self._signature_generator is None: + raise ValueError("Signature generator is required for order signing") + + market_id = self.get_market_id_from_symbol(params.symbol) + nonce = params.nonce if params.nonce is not None else self._get_next_nonce() + deadline = params.deadline if params.deadline is not None else int(time.time()) + DEFAULT_DEADLINE_S + + # TIF <-> expiresAfter coupling, checked against the RESTING order's + # immutable TIF (a modify cannot change TIF — the caller passes the + # resting order's TIF). GTC never expires; GTT must expire after deadline. + expires_after = params.expires_after or 0 + if params.time_in_force == TimeInForce.GTT: + if expires_after == 0: + raise ValueError("GTT orders require a non-zero expires_after greater than deadline") + if expires_after <= deadline: + raise ValueError("GTT expires_after must be greater than deadline") + elif params.time_in_force == TimeInForce.GTC and expires_after != 0: + raise ValueError("GTC orders must not expire (expires_after must be 0)") + + # The signed `OrderDetails.clientOrderId` is the RESTING order's + # clientOrderId — independent of the targeting parameter. When + # targeting BY client_order_id it defaults to that value (mirrors the + # TS SDK: restingClientOrderId ?? clientOrderId ?? 0). + signed_client_order_id = params.resting_client_order_id or params.client_order_id or 0 + trigger_price = Decimal(params.trigger_px) if params.trigger_px is not None else Decimal(0) + + signature = self._signature_generator.sign_order( + account_id=self.config.account_id, + market_id=market_id, + exchange_id=self.config.dex_id, + order_type=int(OrderTypeInt.LIMIT), + is_buy=params.is_buy, + qty=Decimal(params.qty), + limit_price=Decimal(params.limit_px), + trigger_price=trigger_price, + time_in_force=int(TimeInForceInt[params.time_in_force.value]), + client_order_id=signed_client_order_id, + reduce_only=params.reduce_only, + expires_after=expires_after, + nonce=nonce, + deadline=deadline, + post_only=params.post_only, + ) + + payload = { + "orderId": str(params.order_id) if params.order_id is not None else None, + # clientOrderId is the resting order's signed clientOrderId (a restated + # immutable, 0 when the order has none) — NOT the targeting param. The + # ME targets by orderId when present, else by a non-zero clientOrderId. + "clientOrderId": signed_client_order_id, + "symbol": params.symbol, + "accountId": self.config.account_id, + # Restated immutables (full-restate): the request carries every signed + # OrderDetails field. The ME verifies each equals the resting order + # (else MODIFY_IMMUTABLE_MISMATCH). orderType is LIMIT (LIMIT-only). + "exchangeId": self.config.dex_id, + "isBuy": params.is_buy, + "orderType": "LIMIT", + "timeInForce": params.time_in_force.value, + "triggerPx": params.trigger_px, + "reduceOnly": params.reduce_only, + "limitPx": params.limit_px, + "qty": params.qty, + "postOnly": params.post_only, + "expiresAfter": expires_after, + "signature": signature, + "nonce": str(nonce), + "signerWallet": self.signer_wallet_address, + "deadline": deadline, + } + return payload, nonce + async def get_positions(self, wallet_address: Optional[str] = None) -> list[Position]: wallet = wallet_address or self.owner_wallet_address if not wallet: diff --git a/sdk/reya_rest_api/models/__init__.py b/sdk/reya_rest_api/models/__init__.py index de3ad627..bcd5f36a 100644 --- a/sdk/reya_rest_api/models/__init__.py +++ b/sdk/reya_rest_api/models/__init__.py @@ -2,6 +2,6 @@ Data models for Reya Trading API. """ -from .orders import LimitOrderParameters, TriggerOrderParameters +from .orders import LimitOrderParameters, ModifyOrderParameters, TriggerOrderParameters -__all__ = ["LimitOrderParameters", "TriggerOrderParameters"] +__all__ = ["LimitOrderParameters", "ModifyOrderParameters", "TriggerOrderParameters"] diff --git a/sdk/reya_rest_api/models/orders.py b/sdk/reya_rest_api/models/orders.py index fbb5a2fb..d1b44f6c 100644 --- a/sdk/reya_rest_api/models/orders.py +++ b/sdk/reya_rest_api/models/orders.py @@ -25,6 +25,42 @@ class LimitOrderParameters: deadline: Optional[int] = None +@dataclass(frozen=True) +class ModifyOrderParameters: + """Parameters for modifying a resting order in place (spot or perp). + + Target exactly one of `order_id` / `client_order_id` (`client_order_id=0` + is not a valid target). The four modifiable fields — `limit_px`, `qty`, + `post_only`, `expires_after` — are all required and carry the COMPLETE + post-modify state (no omitted-means-inherited shorthand). `qty` is the + TOTAL order quantity, not the remaining, and must exceed the filled amount. + + The EIP-712 signature covers the full post-modify state: the four + modifiable fields at their new values plus the immutables restated from + the resting order — `is_buy` (quantity sign), `time_in_force` (the resting + order's TIF; only GTC is modifiable today, server-enforced), `trigger_px`, + `reduce_only`, and `resting_client_order_id` (the resting order's + clientOrderId, signed into `OrderDetails.clientOrderId` independent of the + targeting parameter; when targeting BY `client_order_id` it defaults to + that value). + """ + + symbol: str + is_buy: bool + limit_px: str + qty: str + post_only: bool + expires_after: int + time_in_force: TimeInForce + order_id: Optional[int] = None + client_order_id: Optional[int] = None + trigger_px: Optional[str] = None + reduce_only: bool = False + resting_client_order_id: int = 0 + deadline: Optional[int] = None + nonce: Optional[int] = None + + @dataclass(frozen=True) class TriggerOrderParameters: """Parameters for a STOP_LOSS or TAKE_PROFIT trigger order on a perp market. diff --git a/sdk/reya_ws_exec/client.py b/sdk/reya_ws_exec/client.py index ba4110ed..f9248d7c 100644 --- a/sdk/reya_ws_exec/client.py +++ b/sdk/reya_ws_exec/client.py @@ -32,14 +32,18 @@ create_connection, ) +from sdk.async_exec_api.cancel_all_after_request import CancelAllAfterRequest as WsCancelAllAfterRequest +from sdk.async_exec_api.cancel_all_after_response import CancelAllAfterResponse as WsCancelAllAfterResponse from sdk.async_exec_api.cancel_order_request import CancelOrderRequest as WsCancelOrderRequest from sdk.async_exec_api.cancel_order_response import CancelOrderResponse as WsCancelOrderResponse from sdk.async_exec_api.create_order_request import CreateOrderRequest as WsCreateOrderRequest from sdk.async_exec_api.create_order_response import CreateOrderResponse as WsCreateOrderResponse from sdk.async_exec_api.mass_cancel_request import MassCancelRequest as WsMassCancelRequest from sdk.async_exec_api.mass_cancel_response import MassCancelResponse as WsMassCancelResponse +from sdk.async_exec_api.modify_order_request import ModifyOrderRequest as WsModifyOrderRequest +from sdk.async_exec_api.modify_order_response import ModifyOrderResponse as WsModifyOrderResponse from sdk.reya_rest_api.client import ReyaTradingClient -from sdk.reya_rest_api.models.orders import LimitOrderParameters, TriggerOrderParameters +from sdk.reya_rest_api.models.orders import LimitOrderParameters, ModifyOrderParameters, TriggerOrderParameters logger = logging.getLogger("reya.ws_exec") @@ -232,6 +236,34 @@ async def mass_cancel( envelope = await self._send_and_await("cancelAll", req) return WsMassCancelResponse.model_validate(envelope) + async def modify_order(self, params: ModifyOrderParameters) -> WsModifyOrderResponse: + """Modify a resting order in place. Same arg semantics as + :meth:`ReyaTradingClient.modify_order`.""" + payload, _nonce = self._rest.build_modify_order_payload(params) + req = WsModifyOrderRequest(**payload) + envelope = await self._send_and_await("modifyOrder", req) + return WsModifyOrderResponse.model_validate(envelope) + + async def cancel_all_after( + self, + timeout_ms: int, + account_id: Optional[int] = None, + deadline: Optional[int] = None, + nonce: Optional[int] = None, + ) -> WsCancelAllAfterResponse: + """Arm, refresh, or disarm the account-wide cancel-all-after countdown + (dead-man's-switch). Same arg semantics as + :meth:`ReyaTradingClient.cancel_all_after`.""" + payload = self._rest.build_cancel_all_after_payload( + timeout_ms=timeout_ms, + account_id=account_id, + deadline=deadline, + nonce=nonce, + ) + req = WsCancelAllAfterRequest(**payload) + envelope = await self._send_and_await("cancelAllAfter", req) + return WsCancelAllAfterResponse.model_validate(envelope) + async def ping(self) -> None: """Send a JSON-layer ping and await the matching pong. diff --git a/specs b/specs index 62055120..95500cf0 160000 --- a/specs +++ b/specs @@ -1 +1 @@ -Subproject commit 620551206440933f2d0de3be1c807b7d18313f60 +Subproject commit 95500cf03a591dde0fdce39a8dce46b075dae927 diff --git a/tests/test_orderbook/__init__.py b/tests/api_contract/__init__.py similarity index 100% rename from tests/test_orderbook/__init__.py rename to tests/api_contract/__init__.py diff --git a/tests/api_contract/conftest.py b/tests/api_contract/conftest.py new file mode 100644 index 00000000..f7b2239f --- /dev/null +++ b/tests/api_contract/conftest.py @@ -0,0 +1,9 @@ +"""Raw API contract suite — EIP-712 envelope + request validation. + +Tests here drive RAW requests (hand-built payloads, real signatures unless +deliberately tampered) and assert SERVER-side rejection. They never produce +fills, so — deliberately — NO balance or position guards are wired for this +directory (moving them out of tests/spot/ also freed them from that +directory's autouse spot_balance_guard, which forced two-account balance +initialization onto tests that never trade). +""" diff --git a/tests/test_spot/test_api_validation.py b/tests/api_contract/test_api_validation.py similarity index 93% rename from tests/test_spot/test_api_validation.py rename to tests/api_contract/test_api_validation.py index fb7955dc..022e26b1 100644 --- a/tests/test_spot/test_api_validation.py +++ b/tests/api_contract/test_api_validation.py @@ -27,8 +27,8 @@ from sdk.reya_rest_api.config import TradingConfig from tests.helpers import ReyaTester from tests.helpers.builders import OrderBuilder +from tests.helpers.market_config import SpotTestConfig from tests.helpers.reya_tester import logger -from tests.test_spot.spot_config import SpotTestConfig # SIGNATURE VALIDATION TESTS # ============================================================================ @@ -607,123 +607,6 @@ async def test_spot_order_old_nonce(spot_config: SpotTestConfig, spot_tester: Re # ============================================================================ -@pytest.mark.spot -@pytest.mark.validation -@pytest.mark.asyncio -async def test_spot_ioc_insufficient_balance_buy(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test that an IOC buy order exceeding RUSD balance is rejected. - - IOC orders have pre-trade balance validation to prevent failed executions. - Gets the actual RUSD balance and tries to exceed it by a small amount. - """ - logger.info("=" * 80) - logger.info("SPOT IOC INSUFFICIENT BALANCE (BUY) TEST") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Get the actual RUSD balance for this account - balances = await spot_tester.client.get_account_balances() - rusd_balance = None - for b in balances: - if b.account_id == spot_tester.account_id and b.asset == "RUSD": - rusd_balance = Decimal(b.real_balance) - break - - if rusd_balance is None or rusd_balance <= 0: - pytest.skip("No RUSD balance available for this test") - assert rusd_balance is not None # narrow after the skip above - - logger.info(f"Current RUSD balance: {rusd_balance}") - - # Calculate qty that would require slightly more RUSD than available - # At spot_config.oracle_price, we need (rusd_balance / price) + small_extra ETH - order_price = Decimal(str(spot_config.oracle_price)) - max_qty_at_price = rusd_balance / order_price - # Request 10% more than we can afford - exceeding_qty = str((max_qty_at_price * Decimal("1.1")).quantize(Decimal("0.01"))) - - order_params = ( - OrderBuilder().symbol(spot_config.symbol).buy().price(str(order_price)).qty(exceeding_qty).ioc().build() - ) - - required_rusd = Decimal(exceeding_qty) * order_price - logger.info(f"Sending IOC buy for {exceeding_qty} ETH @ ${order_price}") - logger.info(f"Required RUSD: {required_rusd}, Available: {rusd_balance}") - - try: - order_id = await spot_tester.orders.create_limit(order_params) - pytest.fail(f"Order exceeding balance should have been rejected, got: {order_id}") - except ApiException as e: - error_msg = str(e) - # Expect: error=CREATE_ORDER_OTHER_ERROR message='Insufficient balance: required X, available Y' - assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" - assert "Insufficient balance" in error_msg, f"Expected 'Insufficient balance' message, got: {e}" - logger.info(f"✅ Order rejected as expected: {type(e).__name__}") - logger.info(f" Error: {str(e)[:150]}") - - await spot_tester.check.no_open_orders() - logger.info("✅ SPOT IOC INSUFFICIENT BALANCE (BUY) TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.validation -@pytest.mark.asyncio -async def test_spot_ioc_insufficient_balance_sell(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test that an IOC sell order exceeding base asset balance is rejected. - - IOC orders have pre-trade balance validation to prevent failed executions. - Gets the actual base asset balance and tries to exceed it by a small amount. - """ - logger.info("=" * 80) - logger.info("SPOT IOC INSUFFICIENT BALANCE (SELL) TEST") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Get the actual base asset balance for this account - base_asset = spot_config.base_asset - balances = await spot_tester.client.get_account_balances() - asset_balance = None - for b in balances: - if b.account_id == spot_tester.account_id and b.asset == base_asset: - asset_balance = Decimal(b.real_balance) - break - - if asset_balance is None or asset_balance <= 0: - pytest.skip(f"No {base_asset} balance available for this test") - assert asset_balance is not None # narrow after the skip above - - logger.info(f"Current {base_asset} balance: {asset_balance}") - - # Request 10% more than we have, quantized to qty_step_size - qty_step = Decimal(spot_config.qty_step_size) if hasattr(spot_config, "qty_step_size") else Decimal("0.01") - exceeding_qty = str((asset_balance * Decimal("1.1")).quantize(qty_step)) - # Round price to tick size - order_price = str(spot_config.price(1.0)) - - order_params = OrderBuilder().symbol(spot_config.symbol).sell().price(order_price).qty(exceeding_qty).ioc().build() - - logger.info(f"Sending IOC sell for {exceeding_qty} {base_asset} @ ${order_price}") - logger.info(f"Required {base_asset}: {exceeding_qty}, Available: {asset_balance}") - - try: - order_id = await spot_tester.orders.create_limit(order_params) - pytest.fail(f"Order exceeding balance should have been rejected, got: {order_id}") - except ApiException as e: - error_msg = str(e) - # Expect: error=CREATE_ORDER_OTHER_ERROR message='Insufficient balance: required X, available Y' - assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" - assert "Insufficient balance" in error_msg, f"Expected 'Insufficient balance' message, got: {e}" - logger.info(f"✅ Order rejected as expected: {type(e).__name__}") - logger.info(f" Error: {str(e)[:150]}") - - await spot_tester.check.no_open_orders() - logger.info("✅ SPOT IOC INSUFFICIENT BALANCE (SELL) TEST COMPLETED") - - # ============================================================================ # PRICE/QTY STEP SIZE VALIDATION TESTS # ============================================================================ diff --git a/tests/api_contract/test_cross_market_smoke.py b/tests/api_contract/test_cross_market_smoke.py new file mode 100644 index 00000000..ef2d0eff --- /dev/null +++ b/tests/api_contract/test_cross_market_smoke.py @@ -0,0 +1,118 @@ +""" +Cross-market smoke for the raw envelope validation path. + +The full raw-validation matrix in test_api_validation.py is pinned to the +SPOT market because signature recovery / nonce tracking / deadline checks run +BEFORE market-specific dispatch — parametrizing all ~24 of them over both +markets would double live cost for near-zero marginal coverage. This smoke +pair proves the equivalence claim on the PERP market: one signature-recovery +rejection and one nonce-replay rejection, signed against the perp marketId. +""" + +import time +from decimal import Decimal + +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.create_order_request import CreateOrderRequest +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.time_in_force import TimeInForce +from tests.helpers import ReyaTester +from tests.helpers.reya_tester import logger + +pytestmark = [pytest.mark.perp, pytest.mark.validation] + +PERP_SYMBOL = "ETHRUSDPERP" + + +def _perp_order_request(tester: ReyaTester, limit_px: str, qty: str, signature: str, nonce: int, deadline: int): + return CreateOrderRequest( + accountId=tester.account_id, + symbol=PERP_SYMBOL, + exchangeId=tester.client.config.dex_id, + isBuy=True, + limitPx=limit_px, + qty=qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + deadline=deadline, + expiresAfter=0, + reduceOnly=None, + signature=signature, + nonce=str(nonce), + signerWallet=tester.client.signer_wallet_address, + ) + + +@pytest.mark.asyncio +async def test_perp_order_invalid_signature_rejected(perp_maker_tester: ReyaTester): + """A tampered signature over a perp-market order is rejected exactly like + the spot equivalent (test_api_validation.py::test_spot_order_invalid_signature).""" + market_def = await perp_maker_tester.get_market_definition(PERP_SYMBOL) + oracle_price = float(await perp_maker_tester.data.current_price(PERP_SYMBOL)) + limit_px = str(round(oracle_price * 0.5, 2)) + + request = _perp_order_request( + perp_maker_tester, + limit_px=limit_px, + qty=str(market_def.min_order_qty), + signature="0x" + "ab" * 65, + nonce=perp_maker_tester.get_next_nonce(), + deadline=int(time.time()) + 60, + ) + + with pytest.raises(ApiException) as exc_info: + await perp_maker_tester.client.orders.create_order(create_order_request=request) + error_msg = str(exc_info.value) + assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {error_msg[:200]}" + assert "Invalid signature" in error_msg, f"Expected 'Invalid signature', got: {error_msg[:200]}" + logger.info("✅ Perp-market invalid signature rejected like spot") + + +@pytest.mark.asyncio +async def test_perp_order_reused_nonce_rejected(perp_maker_tester: ReyaTester): + """Replaying a consumed nonce on a perp-market order is rejected exactly + like the spot equivalent (test_api_validation.py::test_spot_order_reused_nonce).""" + market_def = await perp_maker_tester.get_market_definition(PERP_SYMBOL) + min_qty = str(market_def.min_order_qty) + oracle_price = float(await perp_maker_tester.data.current_price(PERP_SYMBOL)) + limit_px = str(round(oracle_price * 0.5, 2)) + market_id = perp_maker_tester.client.get_market_id_from_symbol(PERP_SYMBOL) + + nonce = perp_maker_tester.get_next_nonce() + deadline = int(time.time()) + 60 + sig_gen = perp_maker_tester.client.signature_generator + + def sign(target_deadline: int) -> str: + return sig_gen.sign_order( + account_id=perp_maker_tester.account_id, + market_id=market_id, + exchange_id=perp_maker_tester.client.config.dex_id, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(min_qty), + limit_price=Decimal(limit_px), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, + nonce=nonce, + deadline=target_deadline, + ) + + first = _perp_order_request(perp_maker_tester, limit_px, min_qty, sign(deadline), nonce, deadline) + response = await perp_maker_tester.client.orders.create_order(create_order_request=first) + assert response.order_id is not None + await perp_maker_tester.client.cancel_order( + order_id=response.order_id, symbol=PERP_SYMBOL, account_id=perp_maker_tester.account_id + ) + + reused_deadline = int(time.time()) + 60 + replay = _perp_order_request(perp_maker_tester, limit_px, min_qty, sign(reused_deadline), nonce, reused_deadline) + with pytest.raises(ApiException) as exc_info: + await perp_maker_tester.client.orders.create_order(create_order_request=replay) + error_msg = str(exc_info.value) + assert "nonce" in error_msg.lower(), f"Expected a nonce rejection, got: {error_msg[:200]}" + logger.info("✅ Perp-market nonce replay rejected like spot") diff --git a/tests/conftest.py b/tests/conftest.py index a6aab811..467cee2d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,8 @@ load_dotenv() +from typing import Optional # noqa: E402 + import asyncio # noqa: E402 import os # noqa: E402 from decimal import Decimal # noqa: E402 @@ -24,16 +26,23 @@ import pytest # noqa: E402 import pytest_asyncio # noqa: E402 +from sdk.open_api.api.wallet_data_api import WalletDataApi # noqa: E402 +from sdk.open_api.api_client import ApiClient # noqa: E402 +from sdk.open_api.configuration import Configuration # noqa: E402 from sdk.open_api.exceptions import ApiException # noqa: E402 from sdk.open_api.models import TimeInForce # noqa: E402 +from sdk.reya_rest_api.config import MAINNET_CHAIN_ID # noqa: E402 from sdk.reya_rest_api.models.orders import LimitOrderParameters # noqa: E402 from tests.helpers import ReyaTester # noqa: E402 -from tests.helpers.reya_tester import logger # noqa: E402 -from tests.test_spot.spot_config import ( # noqa: E402 +from tests.helpers.market_config import ( # noqa: E402 + MarketConfig, + PerpTestConfig, SpotMarketConfig, SpotTestConfig, fetch_spot_market_configs, ) +from tests.helpers.reya_tester import logger # noqa: E402 +from tests.helpers.settlement import make_settlement_probe # noqa: E402 # Time delay between tests TEST_DELAY_SECONDS = 0.1 @@ -46,13 +55,31 @@ def pytest_addoption(parser): - """Add custom command-line options for spot tests.""" + """Add custom command-line options for the live e2e suites.""" parser.addoption( "--spot-asset", action="store", default=DEFAULT_SPOT_ASSET, help="Asset to use for spot tests (e.g., ETH, BTC). Default: ETH", ) + parser.addoption( + "--orderbook-perp-asset", + action="store", + default="ETH", + help="Base asset for [spot, perp]-parametrized tests' perp leg. Symbol becomes RUSDPERP.", + ) + + +def pytest_collection_modifyitems(items): + """Auto-mark [spot]/[perp] param instances so `-m spot` / `-m perp` stay + truthful for the parametrized suites without hand-applied markers.""" + for item in items: + callspec = getattr(item, "callspec", None) + market = callspec.params.get("market_type") if callspec else None + if market == "spot": + item.add_marker(pytest.mark.spot) + elif market == "perp": + item.add_marker(pytest.mark.perp) @pytest.fixture(scope="session") @@ -62,16 +89,142 @@ def spot_asset(request): @pytest_asyncio.fixture(loop_scope="session", scope="function", autouse=True) -async def rate_limit_delay(): +async def rate_limit_delay(request): """ Add a small delay after each test to avoid WAF rate limiting. The staging environment uses AWS WAF which can block requests if too many come from the same IP in a short time window. This delay helps prevent - 403 Forbidden errors during test runs. + 403 Forbidden errors during test runs. Offline-marked tests make no + network calls, so they skip the delay entirely. """ yield - await asyncio.sleep(TEST_DELAY_SECONDS) + if request.node.get_closest_marker("offline") is None: + await asyncio.sleep(TEST_DELAY_SECONDS) + + +# ============================================================================ +# Cancel-on-disconnect (cancelAllAfter) teardown safety +# ============================================================================ +# An armed COD countdown that leaks past a test would mass-cancel a later +# test's resting orders mid-flight. Every tester therefore disarms in +# teardown — idempotently (disarming an unarmed account is a server-side +# no-op) and best-effort (older deployments without the endpoint must not +# tank cleanup). + + +async def _disarm_cod_safely(tester: ReyaTester) -> None: + """Disarm the tester's COD countdown; never fail teardown. + + Swallows ApiException (e.g. 404 on deployments that predate + cancelAllAfter) and transport errors — an armed deadline must never leak + across tests, but a failed disarm must not mask the test's own outcome. + """ + try: + await tester.disarm_cod() + except (ApiException, OSError, RuntimeError, ValueError, asyncio.CancelledError) as e: + logger.debug(f"COD disarm skipped (account {tester.account_id}): {e}") + + +# ============================================================================ +# Execution-busts guard (session-wide settlement-regression tripwire) +# ============================================================================ + +_BUSTS_GUARD_WALLET_ENV_VARS = ( + "PERP_WALLET_ADDRESS_1", + "PERP_WALLET_ADDRESS_2", + "SPOT_WALLET_ADDRESS_1", + "SPOT_WALLET_ADDRESS_2", +) + + +def _busts_guard_wallets() -> list[str]: + """Unique wallet addresses configured via env (order-preserving).""" + wallets: list[str] = [] + for var in _BUSTS_GUARD_WALLET_ENV_VARS: + address = os.environ.get(var) + if address and address not in wallets: + wallets.append(address) + return wallets + + +def _busts_guard_api_url() -> str: + """API base URL resolved the same way TradingConfig.from_env does, but + without requiring wallet/key env vars (the guard is read-only).""" + chain_id = int(os.environ.get("CHAIN_ID", MAINNET_CHAIN_ID)) + if chain_id == MAINNET_CHAIN_ID: + default_api_url = "https://api.reya.xyz/v2" + else: + default_api_url = "https://api-devnet.reya-cronos.network/v2" + return os.environ.get("REYA_API_URL", default_api_url) + + +async def _fetch_execution_bust_counts(wallets: list[str]) -> Optional[dict[str, int]]: + """Fetch the executionBusts count per wallet via a throwaway read-only + API client. Returns None when the API is unreachable (offline/unit runs).""" + api_client = ApiClient(Configuration(host=_busts_guard_api_url())) + try: + wallet_api = WalletDataApi(api_client) + counts: dict[str, int] = {} + for address in wallets: + bust_list = await wallet_api.get_wallet_execution_busts(address=address) + counts[address] = len(bust_list.data or []) + return counts + except (ApiException, OSError, RuntimeError, ValueError) as e: + logger.warning(f"Execution-busts guard: API unreachable, guard disabled: {e}") + return None + finally: + if hasattr(api_client, "rest_client") and api_client.rest_client: + await api_client.rest_client.close() + + +@pytest_asyncio.fixture(loop_scope="session", scope="session", autouse=True) +async def execution_busts_guard(request): + """Session-wide settlement-regression tripwire. + + Records each configured wallet's `GET /v2/wallet/{addr}/executionBusts` + count at session start and asserts it is UNCHANGED at session end — any + new bust during the run means a fill was accepted by the matching engine + but failed settlement, which no test should cause. + + Skips silently when no wallet env vars are configured (unit-only runs + make zero network calls) and degrades gracefully when the API is + unreachable (offline runs). + """ + if all(item.get_closest_marker("offline") for item in request.session.items): + # Offline-only session (e.g. `pytest -m offline` / tests/parity) — + # the guard's API calls are the only network traffic; stay silent. + yield + return + + wallets = _busts_guard_wallets() + if not wallets: + yield + return + + start_counts = await _fetch_execution_bust_counts(wallets) + if start_counts is None: + yield + return + + logger.info(f"🛡️ Execution-busts guard armed: baseline {start_counts}") + yield + + end_counts = await _fetch_execution_bust_counts(wallets) + if end_counts is None: + logger.warning("Execution-busts guard: API unreachable at session end; skipping final check") + return + + changed = { + address: (start_counts[address], end_counts.get(address)) + for address in start_counts + if end_counts.get(address) != start_counts[address] + } + assert not changed, ( + "Execution busts changed during the test session (settlement regression): " + f"{{wallet: (start, end)}} = {changed}" + ) + logger.info("🛡️ Execution-busts guard: no new busts across the session") # ============================================================================ @@ -111,6 +264,7 @@ async def reya_tester_session(): logger.info("🧹 SESSION END: Closing connections") logger.info("=" * 60) try: + await _disarm_cod_safely(tester) if tester.websocket: tester.websocket.close() await tester.positions.close_all(fail_if_none=False) @@ -137,6 +291,9 @@ async def reya_tester(reya_tester_session): # pylint: disable=redefined-outer-n yield reya_tester_session + # Disarm COD first: an armed countdown firing mid-cleanup would race the + # explicit close_all below; an armed deadline must never leak across tests. + await _disarm_cod_safely(reya_tester_session) # Clean up positions and orders after test (connection stays open) await reya_tester_session.positions.close_all(fail_if_none=False) await reya_tester_session.orders.close_all(fail_if_none=False) @@ -173,6 +330,7 @@ async def maker_tester_session(): # Cleanup try: + await _disarm_cod_safely(tester) if tester.websocket: tester.websocket.close() preserve_orders = os.getenv("SPOT_PRESERVE_ACCOUNT1_ORDERS", "").lower() == "true" @@ -212,6 +370,7 @@ async def taker_tester_session(): # Cleanup try: + await _disarm_cod_safely(tester) if tester.websocket: tester.websocket.close() await tester.orders.close_all(fail_if_none=False) @@ -239,6 +398,7 @@ async def maker_tester(maker_tester_session): # pylint: disable=redefined-outer yield maker_tester_session + await _disarm_cod_safely(maker_tester_session) if not preserve_orders: await maker_tester_session.orders.close_all(fail_if_none=False) @@ -262,6 +422,7 @@ async def spot_tester(maker_tester_session): # pylint: disable=redefined-outer- yield maker_tester_session + await _disarm_cod_safely(maker_tester_session) if not preserve_orders: await maker_tester_session.orders.close_all(fail_if_none=False) @@ -276,6 +437,7 @@ async def taker_tester(taker_tester_session): # pylint: disable=redefined-outer yield taker_tester_session + await _disarm_cod_safely(taker_tester_session) await taker_tester_session.orders.close_all(fail_if_none=False) @@ -305,6 +467,7 @@ async def perp_maker_tester_session(): yield tester try: + await _disarm_cod_safely(tester) if tester.websocket: tester.websocket.close() await tester.positions.close_all(fail_if_none=False) @@ -332,6 +495,7 @@ async def perp_taker_tester_session(): yield tester try: + await _disarm_cod_safely(tester) if tester.websocket: tester.websocket.close() await tester.positions.close_all(fail_if_none=False) @@ -342,83 +506,294 @@ async def perp_taker_tester_session(): logger.warning(f"Error during perp taker cleanup: {e}") +async def _restore_to_baseline( # pylint: disable=redefined-outer-name + maker: ReyaTester, + taker: ReyaTester, + symbol: str, + maker_baseline: Decimal, + taker_baseline: Decimal, + min_qty: Decimal, + qty_step: Decimal, +) -> None: + """Trade the test's own net delta back between the two accounts. + + All test fills are maker↔taker crosses, so the deltas mirror (taker +X ⇒ + maker −X) and one reverse cross restores both baselines exactly. The IOC + leg is NOT reduce-only: restoring can legitimately increase an account's + absolute position (e.g. selling back to a short baseline). + """ + maker_delta = await maker.positions.signed_qty(symbol) - maker_baseline + taker_delta = await taker.positions.signed_qty(symbol) - taker_baseline + if abs(maker_delta) < min_qty and abs(taker_delta) < min_qty: + return + + logger.info(f" [restore] maker delta {maker_delta:+}, taker delta {taker_delta:+} on {symbol}") + if maker_delta + taker_delta != Decimal("0"): + logger.warning( + f" [restore] deltas not mirrored (sum {maker_delta + taker_delta:+}) — " + "external fills involved; restoring the reversible overlap only" + ) + if maker_delta == 0 or taker_delta == 0 or (maker_delta > 0) == (taker_delta > 0): + logger.warning(" [restore] deltas not opposite-signed; cannot cross back peer-to-peer") + return + + restore_qty = min(abs(maker_delta), abs(taker_delta)).quantize(qty_step) + if restore_qty < min_qty: + return + + oracle_price = Decimal(str(await maker.data.current_price(symbol))) + cross_price = oracle_price.quantize(Decimal("0.01")) + + # Whoever gained exposure sells it back: the positive-delta side rests + # the GTC SELL, the other side crosses with an IOC BUY. + side_a, side_b = (maker, taker) if maker_delta > 0 else (taker, maker) + logger.info( + f" [restore] account {side_a.account_id} GTC SELL {restore_qty} @ ${cross_price}, " + f"account {side_b.account_id} IOC BUY" + ) + try: + ok = await _execute_perp_flatten( + side_a=side_a, + side_b=side_b, + symbol=symbol, + qty=str(restore_qty), + price=str(cross_price), + side_a_is_buy=False, + ioc_reduce_only=False, + ) + if not ok: + logger.warning(" [restore] restore cross did not complete cleanly") + return + except (ApiException, OSError, RuntimeError) as e: + logger.warning(f" [restore] restore cross threw {type(e).__name__}: {e}") + return + + residual_maker = await maker.positions.signed_qty(symbol) - maker_baseline + residual_taker = await taker.positions.signed_qty(symbol) - taker_baseline + if abs(residual_maker) >= min_qty or abs(residual_taker) >= min_qty: + logger.warning(f" [restore] residual delta after restore: maker {residual_maker:+}, taker {residual_taker:+}") + else: + logger.info(" [restore] ✅ both accounts back at baseline") + + +# ============================================================================ +# [spot, perp] market parametrization (lifted from tests/engine/conftest.py) +# ============================================================================ +# Any test (in any directory) can take `market_config` + `maker`/`taker` and +# run once per market type. ALL resolution is lazy (request.getfixturevalue) +# so an env configured for only one market never spins up the other market's +# sessions — the missing-credential pytest.skip then applies only to that +# market's param instances instead of tanking the suite. + +_DEFAULT_MARKET_TYPES = ("spot", "perp") + + +@pytest_asyncio.fixture(loop_scope="session", scope="session") +async def perp_market_config( + request, perp_maker_tester_session # pylint: disable=redefined-outer-name +) -> PerpTestConfig: + """Fetch the perp market config for parametrized tests. + + Resolves the asset from (in order): the ``--orderbook-perp-asset`` CLI + flag, the ``ORDERBOOK_PERP_ASSET`` env var, then the default ``ETH``. + Metadata comes from the PERP session (historically this pulled the SPOT + maker session, which forced spot-account init on perp-only runs). Skips + if the deployment hasn't enabled this market on the matching engine. + """ + cli_asset = request.config.getoption("--orderbook-perp-asset", default=None) + asset = (cli_asset or os.environ.get("ORDERBOOK_PERP_ASSET", "ETH")).upper() + symbol = f"{asset}RUSDPERP" + + market_def = None + for definition in await perp_maker_tester_session.client.reference.get_perp_market_definitions(): + if definition.symbol == symbol: + market_def = definition + break + + if market_def is None: + pytest.skip(f"Perp market {symbol} not present in /v2/marketDefinitions") + assert market_def is not None # narrows the Optional after the skip above + + # Fail loud rather than swallow the error with a fake price — a wrong oracle + # price silently invalidates every downstream test (limits, liquidity checks, + # circuit-breaker bands). + oracle_price = float(await perp_maker_tester_session.data.current_price(symbol)) + + return PerpTestConfig( + symbol=symbol, + market_id=market_def.market_id, + min_qty=str(market_def.min_order_qty), + qty_step_size=str(market_def.qty_step_size), + oracle_price=oracle_price, + base_asset=asset, + min_balance=float(Decimal(market_def.min_order_qty) * 50), + tick_size=str(market_def.tick_size), + ) + + +@pytest.fixture(params=_DEFAULT_MARKET_TYPES) +def market_type(request) -> str: + """Parametrize over [spot, perp] — the param drives ``market_config``.""" + param: str = request.param + return param + + +@pytest.fixture +def market_config(market_type: str, request) -> MarketConfig: # pylint: disable=redefined-outer-name + """Yield the right per-market config for the active parametrization. + + Resolved LAZILY by market type: requesting the [spot] instance never + touches the perp session and vice versa. (The pre-lift version took both + configs as arguments, which eagerly initialized BOTH market sessions for + every param instance.) + """ + name = "spot_config" if market_type == "spot" else "perp_market_config" + config: MarketConfig = request.getfixturevalue(name) + return config + + +@pytest.fixture +def maker(market_type: str, request): # pylint: disable=redefined-outer-name + """Yield the maker tester for the active parametrization (lazy; see + ``market_config``).""" + return request.getfixturevalue("perp_maker_tester" if market_type == "perp" else "maker_tester") + + +@pytest.fixture +def taker(market_type: str, request): # pylint: disable=redefined-outer-name + """Yield the taker tester for the active parametrization (lazy; see + ``market_config``).""" + return request.getfixturevalue("perp_taker_tester" if market_type == "perp" else "taker_tester") + + +@pytest.fixture +def settlement_probe(market_type, market_config, maker, taker): # pylint: disable=redefined-outer-name + """A market-matched settlement probe for [spot, perp]-parametrized FILL + tests: spot asserts exact zero-fee balance deltas, perp asserts ±qty signed + position deltas. ``maker`` is the aggressor (buyer), ``taker`` the resting + counterparty (seller).""" + return make_settlement_probe(market_type, market_config, buyer=maker, seller=taker) + + +@pytest.fixture +def settlement_cleanup_guard(market_type, request): # pylint: disable=redefined-outer-name + """Post-test settlement cleanup for a [spot, perp]-parametrized FILL test: + spot wires the session ``spot_balance_guard`` (balances restored at session + end); perp is a no-op here because the perp baseline-restore activates + transitively via the maker/taker → perp fixtures. Request it (autouse) only + from modules that actually produce fills, so rejection-only and read-only + tests never spin up the spot sessions.""" + if market_type == "spot": + request.getfixturevalue("spot_balance_guard") + yield + + +# ============================================================================ +# Perp-skip canary +# ============================================================================ +# The lazy resolution above has one dangerous regression mode: a bug that +# makes EVERY [perp] param instance skip at setup while perp credentials are +# present would leave the suite green with zero perp coverage. Track +# setup-phase outcomes of perp-param instances and fail the run if all of +# them skipped. (In-test skips — e.g. external-liquidity guards — happen in +# the 'call' phase and don't trip this.) Kill switch: PERP_SKIP_CANARY=off. + +_perp_param_setup_stats = {"total": 0, "skipped": 0} + + +def pytest_runtest_logreport(report): + if report.when != "setup" or "[perp" not in report.nodeid: + return + _perp_param_setup_stats["total"] += 1 + if report.skipped: + _perp_param_setup_stats["skipped"] += 1 + + +def pytest_sessionfinish(session, exitstatus): # pylint: disable=unused-argument + if os.environ.get("PERP_SKIP_CANARY", "").lower() == "off": + return + stats = _perp_param_setup_stats + if stats["total"] >= 3 and stats["skipped"] == stats["total"] and os.environ.get("PERP_ACCOUNT_ID_1"): + print( + "\n[perp-skip canary] every [perp] param instance " + f"({stats['total']}) skipped at setup while PERP credentials are present — " + "perp coverage silently vanished (lazy fixture regression or perp market " + "missing from /v2/marketDefinitions). Set PERP_SKIP_CANARY=off to override." + ) + session.exitstatus = 1 + + @pytest_asyncio.fixture(loop_scope="session", scope="function") -async def perp_flatten_between_tests( # pylint: disable=redefined-outer-name,unused-argument +async def perp_baseline_restore( # pylint: disable=redefined-outer-name,unused-argument perp_maker_tester_session, perp_taker_tester_session, perp_position_guard ): - """Function-scoped orchestrated flatten run before & after every perp test. + """Capture both perp accounts' signed positions before each test and trade + the test's own delta back afterwards. - Mirrors what `perp_position_guard` does at session boundaries, but at - per-test scope. Required because under perpOB a one-sided reduce-only IOC - has no AMM counterparty, so the per-test `close_all` legitimately skips and - leaves mirrored debris from a previous test that the next test may need - cleared (most position-management tests assert on an empty starting - position via `check.position_not_open`). + Perp tests are baseline-relative: whatever position an account holds when + a test starts is the baseline it asserts signed deltas against (via + `check.position_delta`), and this teardown crosses the net delta back + between the two accounts so each test leaves them exactly as it got them. + Pre-existing (possibly dirty) state therefore never fails a test — tests + that genuinely need a flat account `pytest.skip` themselves instead. Activated transitively through `perp_maker_tester` / `perp_taker_tester`. - `_flatten_to_zero` short-circuits when nothing needs flattening, so the - overhead on tests that don't accumulate debris is just two position - queries (~100ms). + Teardown ordering matters: the wrappers' `orders.close_all` finalizers run + first (clearing any leftover resting GTC), then this restore cross runs on + a clean book. """ market_def = await perp_maker_tester_session.get_market_definition(PERP_GUARD_SYMBOL) min_qty = Decimal(str(market_def.min_order_qty)) qty_step = Decimal(str(market_def.qty_step_size)) - await _flatten_to_zero( - maker=perp_maker_tester_session, - taker=perp_taker_tester_session, - symbol=PERP_GUARD_SYMBOL, - min_qty=min_qty, - qty_step=qty_step, - label="pre-test", - ) + maker_baseline = await perp_maker_tester_session.positions.signed_qty(PERP_GUARD_SYMBOL) + taker_baseline = await perp_taker_tester_session.positions.signed_qty(PERP_GUARD_SYMBOL) yield - await _flatten_to_zero( + await _restore_to_baseline( maker=perp_maker_tester_session, taker=perp_taker_tester_session, symbol=PERP_GUARD_SYMBOL, + maker_baseline=maker_baseline, + taker_baseline=taker_baseline, min_qty=min_qty, qty_step=qty_step, - label="post-test", ) @pytest_asyncio.fixture(loop_scope="session", scope="function") async def perp_maker_tester( # pylint: disable=redefined-outer-name,unused-argument - perp_maker_tester_session, perp_flatten_between_tests + perp_maker_tester_session, perp_baseline_restore ): - """Function-scoped perp maker — clears orders/positions/WS state between tests. + """Function-scoped perp maker — clears orders/WS state between tests. - Requests `perp_flatten_between_tests` (transitively activates - `perp_position_guard`) so per-test debris is orchestrated-flattened - rather than left to a best-effort one-sided IOC. + Positions are deliberately NOT closed here: perp tests are baseline- + relative (see `perp_baseline_restore`), and a one-sided `close_all` + would eat the very baseline the restore fixture preserves. """ await perp_maker_tester_session.orders.close_all(fail_if_none=False) - await perp_maker_tester_session.positions.close_all(fail_if_none=False) perp_maker_tester_session.ws.clear() yield perp_maker_tester_session + await _disarm_cod_safely(perp_maker_tester_session) await perp_maker_tester_session.orders.close_all(fail_if_none=False) - await perp_maker_tester_session.positions.close_all(fail_if_none=False) @pytest_asyncio.fixture(loop_scope="session", scope="function") async def perp_taker_tester( # pylint: disable=redefined-outer-name,unused-argument - perp_taker_tester_session, perp_flatten_between_tests + perp_taker_tester_session, perp_baseline_restore ): - """Function-scoped perp taker — clears orders/positions/WS state between tests. + """Function-scoped perp taker — clears orders/WS state between tests. - Requests `perp_flatten_between_tests` (transitively activates - `perp_position_guard`) so per-test debris is orchestrated-flattened - rather than left to a best-effort one-sided IOC. + Positions are deliberately NOT closed here: perp tests are baseline- + relative (see `perp_baseline_restore`), and a one-sided `close_all` + would eat the very baseline the restore fixture preserves. """ await perp_taker_tester_session.orders.close_all(fail_if_none=False) - await perp_taker_tester_session.positions.close_all(fail_if_none=False) perp_taker_tester_session.ws.clear() yield perp_taker_tester_session + await _disarm_cod_safely(perp_taker_tester_session) await perp_taker_tester_session.orders.close_all(fail_if_none=False) - await perp_taker_tester_session.positions.close_all(fail_if_none=False) # ============================================================================ @@ -431,16 +806,6 @@ async def perp_taker_tester( # pylint: disable=redefined-outer-name,unused-argu # with an IOC, and both positions move consistently. -async def _get_perp_position_qty(tester: ReyaTester, symbol: str) -> Decimal: - """Return the signed position size (+ long, − short) on `symbol`, or 0.""" - pos = await tester.data.position(symbol) - if pos is None or pos.qty is None: - return Decimal("0") - qty = Decimal(str(pos.qty)) - # Sides on the API: 'B' = Bid/long (positive), 'A' = Ask/short (negative). - return qty if str(pos.side) in ("Side.B", "B") else -qty - - async def _execute_perp_flatten( side_a: ReyaTester, side_b: ReyaTester, @@ -448,6 +813,7 @@ async def _execute_perp_flatten( qty: str, price: str, side_a_is_buy: bool, + ioc_reduce_only: bool = True, ) -> bool: """Cross a GTC on one side with a matching IOC on the other to move both testers' positions by ±qty in opposite directions. @@ -457,15 +823,14 @@ async def _execute_perp_flatten( - `side_b` places the opposite-direction IOC at the same price. - On match, side_a moves +qty (if buy) / -qty (if sell), side_b mirrors. - Used by `perp_position_guard` to converge accumulated positions back - toward zero at session end. For mirrored positions (5: -X, 6: +X — the - common case after a maker/taker test) this restores both to zero. - For unmirrored residue (e.g. accumulated drift), it reduces by qty. + `ioc_reduce_only` controls the IOC leg: flatten-to-zero callers keep the + default (the IOC always reduces toward zero there); baseline-restore + callers MUST pass False, because trading a test's delta back can + legitimately increase an account's absolute position (e.g. selling back + to a short baseline). """ - # API rejects `reduceOnly` on GTC (only valid for IOC and TP/SL). The - # GTC is left as a regular limit; it's reduce-by-construction because the - # caller passed `qty <= |side_a position|`. The IOC sets reduce_only=True - # because the API requires it on perp IOCs. + # API rejects `reduceOnly` on GTC (only valid for IOC and TP/SL), so the + # GTC leg is always a plain limit. gtc_params = LimitOrderParameters( symbol=symbol, is_buy=side_a_is_buy, @@ -487,7 +852,7 @@ async def _execute_perp_flatten( limit_px=price, qty=qty, time_in_force=TimeInForce.IOC, - reduce_only=True, + reduce_only=ioc_reduce_only, ) await side_b.client.create_limit_order(ioc_params) @@ -514,7 +879,7 @@ async def _execute_perp_flatten( PERP_GUARD_SYMBOL = "ETHRUSDPERP" -async def _flatten_to_zero( +async def _flatten_to_zero( # pylint: disable=redefined-outer-name maker: ReyaTester, taker: ReyaTester, symbol: str, @@ -533,8 +898,8 @@ async def _flatten_to_zero( `label` is just for log readability ("session start" / "session end"). """ - maker_qty = await _get_perp_position_qty(maker, symbol) - taker_qty = await _get_perp_position_qty(taker, symbol) + maker_qty = await maker.positions.signed_qty(symbol) + taker_qty = await taker.positions.signed_qty(symbol) logger.info(f" [{label}] maker (account {maker.account_id}): {maker_qty} {symbol}") logger.info(f" [{label}] taker (account {taker.account_id}): {taker_qty} {symbol}") @@ -627,7 +992,7 @@ async def perp_position_guard( # pylint: disable=redefined-outer-name symbol = PERP_GUARD_SYMBOL # Pull market metadata from the maker tester so we don't depend on the - # perp_market_config fixture (which lives in tests/test_orderbook/conftest). + # perp_market_config fixture (which lives in tests/engine/conftest). market_def = await perp_maker_tester_session.get_market_definition(symbol) min_qty = Decimal(str(market_def.min_order_qty)) qty_step = Decimal(str(market_def.qty_step_size)) @@ -702,25 +1067,25 @@ async def test_something(spot_config, maker_tester): symbol = spot_config.symbol Run tests for different assets: - pytest tests/test_spot/ -v --spot-asset=ETH - pytest tests/test_spot/ -v --spot-asset=BTC + pytest tests/spot/ -v --spot-asset=ETH + pytest tests/spot/ -v --spot-asset=BTC """ # Validate the selected asset is available if spot_asset not in spot_market_configs: available = sorted(spot_market_configs.keys()) pytest.skip(f"Asset '{spot_asset}' not available. Available assets: {', '.join(available)}") - market_config: SpotMarketConfig = spot_market_configs[spot_asset] + selected_market: SpotMarketConfig = spot_market_configs[spot_asset] logger.info("=" * 60) logger.info(f"🎯 SPOT TESTS CONFIGURED FOR: {spot_asset}") - logger.info(f" Symbol: {market_config.symbol}") - logger.info(f" Min Order Qty: {market_config.min_order_qty}") - logger.info(f" Min Balance: {market_config.min_balance}") + logger.info(f" Symbol: {selected_market.symbol}") + logger.info(f" Min Order Qty: {selected_market.min_order_qty}") + logger.info(f" Min Balance: {selected_market.min_balance}") logger.info("=" * 60) # Fetch oracle price using PERP symbol (e.g., ETHRUSDPERP, BTCRUSDPERP) - oracle_symbol = market_config.oracle_symbol + oracle_symbol = selected_market.oracle_symbol try: price_str = await maker_tester_session.data.current_price(oracle_symbol) @@ -734,13 +1099,14 @@ async def test_something(spot_config, maker_tester): logger.warning(f"Using fallback oracle price: ${oracle_price:.2f}") return SpotTestConfig( - symbol=market_config.symbol, - market_id=market_config.market_id, - min_qty=market_config.min_order_qty, - qty_step_size=market_config.qty_step_size, + symbol=selected_market.symbol, + market_id=selected_market.market_id, + min_qty=selected_market.min_order_qty, + qty_step_size=selected_market.qty_step_size, oracle_price=oracle_price, - base_asset=market_config.base_asset, - min_balance=float(market_config.min_balance), + base_asset=selected_market.base_asset, + min_balance=float(selected_market.min_balance), + tick_size=selected_market.tick_size, ) diff --git a/tests/test_perps/__init__.py b/tests/engine/__init__.py similarity index 100% rename from tests/test_perps/__init__.py rename to tests/engine/__init__.py diff --git a/tests/engine/post_only_helpers.py b/tests/engine/post_only_helpers.py new file mode 100644 index 00000000..b5f5f206 --- /dev/null +++ b/tests/engine/post_only_helpers.py @@ -0,0 +1,70 @@ +"""Shared helpers for the post-only e2e suite.""" + +from __future__ import annotations + +import asyncio +import time + +from sdk.async_api.order import Order as AsyncOrder +from sdk.open_api.models.order import Order +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.order_lifecycle import wait_for_order_fields + +PERP_SYMBOL = "ETHRUSDPERP" + + +async def rest_gtc_at_price( + tester: ReyaTester, + symbol: str, + price: str, + qty: str, + is_buy: bool = True, + post_only: bool = False, +) -> Order: + """Place a GTC at an explicit price, wait for creation, return the fetched Order. + + Symbol/price/qty are explicit (rather than config-derived as in + tests/helpers/order_lifecycle.rest_spot_gtc) so the same helper rests + both spot and perp makers. + """ + builder = OrderBuilder().symbol(symbol).side(is_buy).price(price).qty(qty).gtc() + if post_only: + builder = builder.post_only() + order_id = await tester.orders.create_limit(builder.build()) + assert order_id is not None, "GTC creation must return an order_id" + await tester.wait.for_order_creation(order_id) + # for_order_creation may return on the WS-only path before the REST + # openOrders cache (OrdersProvider) reflects the order — poll, never + # single-shot. + return await wait_for_order_fields(tester, order_id) + + +async def wait_for_ws_order_event(tester: ReyaTester, order_id: str, timeout_s: float = 10.0) -> AsyncOrder: + """Bounded poll of the tester's WS order-changes store for `order_id`. + + `wait.for_order_creation` may return on the REST-only path before the WS + event lands, so tests that assert on WS-event FIELDS (e.g. postOnly) wait + here first. + """ + deadline = time.time() + timeout_s + while time.time() < deadline: + ws_order = tester.ws.orders.get(str(order_id)) + if ws_order is not None: + return ws_order + await asyncio.sleep(0.1) + raise AssertionError(f"No WS order event for {order_id} within {timeout_s}s") + + +async def open_order_ids(tester: ReyaTester) -> set[str]: + """Snapshot of the account's open order ids — for asserting that a + rejected post-only probe left openOrders unchanged.""" + return {str(order.order_id) for order in await tester.client.get_open_orders()} + + +async def perp_min_qty_and_oracle(tester: ReyaTester, symbol: str = PERP_SYMBOL) -> tuple[str, float]: + """Market min_order_qty and current oracle price (mirrors how the modify + suite resolves perp market metadata inline).""" + market_def = await tester.get_market_definition(symbol) + oracle_price = float(await tester.data.current_price(symbol)) + return str(market_def.min_order_qty), oracle_price diff --git a/tests/engine/test_cod_lifecycle.py b/tests/engine/test_cod_lifecycle.py new file mode 100644 index 00000000..ec599999 --- /dev/null +++ b/tests/engine/test_cod_lifecycle.py @@ -0,0 +1,279 @@ +""" +Cancel-on-disconnect (cancelAllAfter) lifecycle tests — live e2e. + +Exercises the account-wide dead-man's-switch end to end: +- arm echoes the countdown deadline (`triggerAt` ≈ now + timeoutMs on the + MATCHING-ENGINE clock — assertions use a ±2s window to absorb client↔ME + clock skew and request latency), +- expiry mass-cancels ALL of the account's resting orders (same scope as + an account-wide cancelAll), +- refresh replaces the countdown, disarm (timeoutMs=0) stops it, +- the switch is strictly account-scoped, and state fully resets after a fire. + +The account-wide fire (`test_cod_fires_cancels_all_orders`) is parametrized +over [spot, perp]: it rests orders on whichever market the parametrization +picked and asserts the fire empties the account. The countdown STATE-MACHINE +tests (arm-echo, refresh, disarm, account-isolation, rearm) stay single-market +— COD is an account-level dead-man's-switch with no market dimension in its +request or countdown; the market only enters at fire SCOPE, which the +parametrized fire test covers. + +RECORDED COVERAGE GAP: the devnet fixtures keep spot and perp credentials on +DISJOINT accounts (SPOT_ACCOUNT_ID_* cannot trade perps and the +PERP_ACCOUNT_ID_* margin accounts carry no spot balances), and devnet1 lists +exactly ONE spot and ONE perp market — so no configured account can rest +orders on two markets at once. The multi-MARKET scope of a single fire (one +expiry emptying an account's orders resting on >=2 markets) is therefore NOT +exercisable e2e — a reactor bug that scoped the fire's cancel to a single +market would pass this suite. That dimension is pinned at the matching-engine +level instead: reya-chain `crates/matching-engine-server/tests/ +cancel_all_after_test.rs::cod_fire_cancels_only_target_account_across_markets` +rests target-account orders in two markets (plus a control account's order), +fires once, and asserts one MassCancel per affected market with both of the +target's books emptied and the control account untouched. Revisit if devnet1 +ever lists a second market reachable from a single configured account. + +The function-scoped tester fixtures disarm COD and close all orders in +teardown, so an assertion failure mid-test never leaks an armed countdown +or a resting order onto the shared devnet accounts. +""" + +import asyncio +import time + +import pytest + +from sdk.open_api.models.cancel_all_after_response import CancelAllAfterResponse +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig +from tests.helpers.reya_tester import logger + +pytestmark = [pytest.mark.e2e, pytest.mark.cod] + +# The ME scans armed countdowns on a ~500ms tick; allow that plus clock skew +# and request latency before declaring a fire missed (or a non-fire proven). +FIRE_MARGIN_S = 3.0 +TRIGGER_AT_WINDOW_MS = 2_000 + + +def _assert_trigger_at_echo(response: CancelAllAfterResponse, timeout_ms: int, sent_at_ms: float) -> None: + """Assert the arm response echoes timeoutMs and a triggerAt ≈ sent_at + timeoutMs. + + `triggerAt` is stamped by the MATCHING-ENGINE clock; ±TRIGGER_AT_WINDOW_MS + absorbs client↔ME skew + round-trip latency. + """ + assert response.timeout_ms == timeout_ms, f"Expected timeoutMs echo {timeout_ms}, got {response.timeout_ms}" + assert response.trigger_at is not None, f"Armed countdown must echo triggerAt: {response}" + expected = sent_at_ms + timeout_ms + drift = response.trigger_at - expected + assert abs(drift) <= TRIGGER_AT_WINDOW_MS, ( + f"triggerAt {response.trigger_at} drifts {drift:+.0f}ms from now+timeoutMs {expected:.0f} " + f"(window ±{TRIGGER_AT_WINDOW_MS}ms; ME clock vs client clock)" + ) + + +async def _wait_until_no_open_orders(tester: ReyaTester, timeout_s: float) -> None: + """Poll REST open orders until the account has none (bounded).""" + deadline = time.time() + timeout_s + remaining: list = [] + while time.time() < deadline: + remaining = await tester.client.get_open_orders() + if not remaining: + return + await asyncio.sleep(0.5) + raise AssertionError( + f"Account {tester.account_id} still has {len(remaining)} open order(s) after {timeout_s}s: " + f"{[o.order_id for o in remaining]}" + ) + + +async def _order_is_open(tester: ReyaTester, order_id: str) -> bool: + return await tester.data.open_order(order_id) is not None + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_arm_echoes_trigger_at(spot_tester: ReyaTester): + """arm(30000) echoes timeoutMs and triggerAt ≈ now+30s (ME clock, ±2s).""" + sent_at_ms = time.time() * 1000 + response = await spot_tester.arm_cod(timeout_ms=30_000) + logger.info(f"armed: {response}") + + try: + _assert_trigger_at_echo(response, timeout_ms=30_000, sent_at_ms=sent_at_ms) + assert response.account_id == spot_tester.account_id + finally: + disarm = await spot_tester.disarm_cod() + + assert disarm.timeout_ms == 0 + assert disarm.trigger_at is None, f"Disarm must not echo a triggerAt: {disarm}" + + +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_cod_fires_cancels_all_orders( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester +) -> None: + """COD expiry cancels EVERY resting order on the account, across BOTH book + sides. + + Account-wide scope is exercised on whichever market the parametrization + picked, with multiple resting GTC orders on both sides of the book (no + fills — the orders rest far from oracle and are cancelled by the fire). + The cross-MARKET scope of a single fire (one expiry emptying an account's + orders resting on >=2 markets at once) is NOT reachable e2e because no + configured devnet account spans both market types — see the module + docstring; that dimension is pinned at the matching-engine level instead. + """ + far_buy = str(market_config.price(0.50)) + far_sell = str(market_config.price(1.50)) + + order_ids = [] + for params in ( + OrderBuilder().symbol(market_config.symbol).buy().price(far_buy).qty(market_config.min_qty).gtc().build(), + OrderBuilder().symbol(market_config.symbol).buy().price(far_buy).qty(market_config.min_qty).gtc().build(), + OrderBuilder().symbol(market_config.symbol).sell().price(far_sell).qty(market_config.min_qty).gtc().build(), + ): + order_id = await maker.orders.create_limit(params) + assert order_id is not None + order_ids.append(order_id) + for order_id in order_ids: + await maker.wait.for_order_creation(order_id) + logger.info(f"[{market_type}] resting orders before arm: {order_ids}") + + await maker.arm_cod(timeout_ms=5_000) + + # timeout + scan granularity + clock-skew margin. + await _wait_until_no_open_orders(maker, timeout_s=5.0 + FIRE_MARGIN_S) + logger.info(f"[{market_type}] ✅ COD fired: all orders cancelled") + + # The fire must also notify over WS: an orderChange with status CANCELLED + # per order (WS is the authoritative status source in for_order_state). + for order_id in order_ids: + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED) + logger.info(f"[{market_type}] ✅ WS delivered CANCELLED orderChanges for all {len(order_ids)} orders") + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_refresh_extends_deadline(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """Re-arming replaces the countdown: a refresh before expiry pushes the + deadline out, so the order survives past the ORIGINAL deadline.""" + params = OrderBuilder.from_config(spot_config).buy().price(str(spot_config.get_safe_no_match_buy_price())).build() + order_id = await spot_tester.orders.create_limit(params) + assert order_id is not None + await spot_tester.wait.for_order_creation(order_id) + + try: + armed = await spot_tester.arm_cod(timeout_ms=5_000) + assert armed.trigger_at is not None + # Sleep meaningfully into the original countdown, but leave ~3s of + # budget (the ME-side deadline started BEFORE the arm response came + # back) so a slow devnet/WAF round trip can't let the original fire. + await asyncio.sleep(2.0) + + sent_at_ms = time.time() * 1000 + refresh = await spot_tester.arm_cod(timeout_ms=60_000) + _assert_trigger_at_echo(refresh, timeout_ms=60_000, sent_at_ms=sent_at_ms) + assert refresh.trigger_at is not None + # Both triggerAts are on the SAME (ME) clock: triggerAt - timeoutMs is + # when the ME processed each arm. If the refresh provably landed after + # the original deadline, the fire was legitimate — skip, don't flake. + if refresh.trigger_at - 60_000 >= armed.trigger_at: + pytest.skip( + f"refresh round trip too slow: ME processed it at {refresh.trigger_at - 60_000} " + f"≥ original deadline {armed.trigger_at} (ME clock) — cannot prove refresh semantics" + ) + + # t≈7s > original 5s deadline; only the refreshed 60s countdown runs. + await asyncio.sleep(5.0) + assert await _order_is_open(spot_tester, order_id), ( + f"Order {order_id} was cancelled past the ORIGINAL deadline — " "the refresh did not replace the countdown" + ) + logger.info("✅ Refresh extended the deadline; order survived the original expiry") + finally: + await spot_tester.disarm_cod() + await spot_tester.orders.close_all(fail_if_none=False) + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_disarm_prevents_fire(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """Disarming (timeoutMs=0) stops the countdown: nothing fires after the + would-have-been deadline.""" + params = OrderBuilder.from_config(spot_config).buy().price(str(spot_config.get_safe_no_match_buy_price())).build() + order_id = await spot_tester.orders.create_limit(params) + assert order_id is not None + await spot_tester.wait.for_order_creation(order_id) + + try: + await spot_tester.arm_cod(timeout_ms=5_000) + disarm = await spot_tester.disarm_cod() + assert disarm.timeout_ms == 0 + assert disarm.trigger_at is None, f"Disarm must not echo a triggerAt: {disarm}" + + # Past the original 5s deadline + scan/clock margin: nothing may fire. + await asyncio.sleep(7.0) + assert await _order_is_open(spot_tester, order_id), f"Order {order_id} was cancelled after a disarm" + logger.info("✅ Disarm prevented the fire; order still resting") + finally: + await spot_tester.orders.close_all(fail_if_none=False) + + +@pytest.mark.spot +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_account_isolation(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): + """Account A arming + firing must not touch account B's resting orders.""" + buy_px = str(spot_config.get_safe_no_match_buy_price()) + + maker_order_id = await maker_tester.orders.create_limit( + OrderBuilder.from_config(spot_config).buy().price(buy_px).gtc().build() + ) + assert maker_order_id is not None + await maker_tester.wait.for_order_creation(maker_order_id) + + taker_order_id = await taker_tester.orders.create_limit( + OrderBuilder.from_config(spot_config).buy().price(buy_px).gtc().build() + ) + assert taker_order_id is not None + await taker_tester.wait.for_order_creation(taker_order_id) + + try: + await maker_tester.arm_cod(timeout_ms=5_000) + await _wait_until_no_open_orders(maker_tester, timeout_s=5.0 + FIRE_MARGIN_S) + logger.info("✅ Account A's COD fired") + + assert await _order_is_open(taker_tester, taker_order_id), ( + f"Account B's order {taker_order_id} was cancelled by account A's COD fire — " "the switch leaked scope" + ) + logger.info("✅ Account B's resting order untouched") + finally: + await taker_tester.orders.close_all(fail_if_none=False) + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_rearm_after_fire(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """After a fire completes, the account can arm again and disarm cleanly — + the fired countdown leaves no stuck server-side state.""" + params = OrderBuilder.from_config(spot_config).buy().price(str(spot_config.get_safe_no_match_buy_price())).build() + order_id = await spot_tester.orders.create_limit(params) + assert order_id is not None + await spot_tester.wait.for_order_creation(order_id) + + await spot_tester.arm_cod(timeout_ms=5_000) + await _wait_until_no_open_orders(spot_tester, timeout_s=5.0 + FIRE_MARGIN_S) + logger.info("✅ First COD fire completed") + + sent_at_ms = time.time() * 1000 + rearm = await spot_tester.arm_cod(timeout_ms=30_000) + _assert_trigger_at_echo(rearm, timeout_ms=30_000, sent_at_ms=sent_at_ms) + logger.info("✅ Re-arm accepted after the fire") + + disarm = await spot_tester.disarm_cod() + assert disarm.timeout_ms == 0 + assert disarm.trigger_at is None + logger.info("✅ Disarm after re-arm: COD state fully reset") diff --git a/tests/engine/test_cod_validation.py b/tests/engine/test_cod_validation.py new file mode 100644 index 00000000..0394a28c --- /dev/null +++ b/tests/engine/test_cod_validation.py @@ -0,0 +1,111 @@ +""" +Cancel-on-disconnect (cancelAllAfter) server-side validation tests — live e2e. + +The SDK's `build_cancel_all_after_payload` rejects out-of-range timeouts +locally (pinned offline in tests/validation/test_client_guards.py), so these +tests bypass the client guard and drive RAW requests through the generated +`OrderEntryApi` — each carries a REAL EIP-712 signature over the same +`timeoutMs` that goes on the wire (except the deliberate tamper case), so the +server's INPUT validation is what rejects, not signature recovery. + +Mirrors the raw-request precedent in tests/spot/test_api_validation.py: +typed generated request models posted via `tester.client.orders.`, with +error codes asserted on the ApiException body text. +""" + +import time + +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.cancel_all_after_request import CancelAllAfterRequest +from tests.helpers import ReyaTester +from tests.helpers.reya_tester import logger + +pytestmark = [pytest.mark.e2e, pytest.mark.cod, pytest.mark.validation] + + +def _raw_cancel_all_after_request( + tester: ReyaTester, + timeout_ms: int, + signed_timeout_ms: int | None = None, +) -> CancelAllAfterRequest: + """Build a CancelAllAfterRequest signed over `signed_timeout_ms` + (defaults to the wire `timeout_ms` — a fully honest request).""" + nonce = tester.get_next_nonce() + deadline = int(time.time()) + 60 + signature = tester.client.signature_generator.sign_cancel_all_after( + account_id=tester.account_id, + timeout_ms=signed_timeout_ms if signed_timeout_ms is not None else timeout_ms, + nonce=nonce, + deadline=deadline, + ) + return CancelAllAfterRequest( + accountId=tester.account_id, + timeoutMs=timeout_ms, + signature=signature, + nonce=str(nonce), + signerWallet=tester.client.signer_wallet_address, + deadline=deadline, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "timeout_ms,accepted", + [ + (1, False), + (4_999, False), + (60_001, False), + (5_000, True), + (60_000, True), + (0, True), + ], +) +async def test_timeout_validation_via_api(spot_tester: ReyaTester, timeout_ms: int, accepted: bool): + """Server-side timeoutMs bounds: {0} ∪ [5000, 60000] accepted, everything + else INPUT_VALIDATION_ERROR. Raw requests so the client guard can't + pre-empt the server; every payload is signed over the timeoutMs it sends.""" + request = _raw_cancel_all_after_request(spot_tester, timeout_ms=timeout_ms) + + if not accepted: + with pytest.raises(ApiException) as exc_info: + await spot_tester.client.orders.cancel_all_after(request) + error_msg = str(exc_info.value) + assert ( + "INPUT_VALIDATION_ERROR" in error_msg + ), f"Expected INPUT_VALIDATION_ERROR for {timeout_ms}, got: {error_msg[:200]}" + logger.info(f"✅ timeoutMs={timeout_ms} rejected with INPUT_VALIDATION_ERROR") + return + + response = await spot_tester.client.orders.cancel_all_after(request) + try: + assert response.timeout_ms == timeout_ms + if timeout_ms == 0: + assert response.trigger_at is None, f"Disarm must not echo a triggerAt: {response}" + else: + assert response.trigger_at is not None, f"Armed countdown must echo a triggerAt: {response}" + logger.info(f"✅ timeoutMs={timeout_ms} accepted: {response}") + finally: + # Never leak an armed countdown past the test (the fixture also + # disarms in teardown, but be explicit per arm). + if timeout_ms != 0: + await spot_tester.disarm_cod() + + +@pytest.mark.asyncio +async def test_tampered_signature_rejected(spot_tester: ReyaTester): + """Sign timeoutMs=30000 but send timeoutMs=40000: the recovered signer + saw different bytes than the wire payload → UNAUTHORIZED_SIGNATURE_ERROR, + and the account must NOT end up armed.""" + request = _raw_cancel_all_after_request(spot_tester, timeout_ms=40_000, signed_timeout_ms=30_000) + + with pytest.raises(ApiException) as exc_info: + await spot_tester.client.orders.cancel_all_after(request) + error_msg = str(exc_info.value) + assert "UNAUTHORIZED_SIGNATURE_ERROR" in error_msg, f"Expected UNAUTHORIZED_SIGNATURE_ERROR, got: {error_msg[:200]}" + logger.info("✅ Tampered cancelAllAfter rejected with UNAUTHORIZED_SIGNATURE_ERROR") + + # Defensive: prove no countdown was armed by the rejected request. + disarm = await spot_tester.disarm_cod() + assert disarm.trigger_at is None diff --git a/tests/engine/test_cod_ws_exec.py b/tests/engine/test_cod_ws_exec.py new file mode 100644 index 00000000..39f6224b --- /dev/null +++ b/tests/engine/test_cod_ws_exec.py @@ -0,0 +1,279 @@ +""" +Cancel-on-disconnect (cancelAllAfter) over ws-exec — live e2e. + +Same arm/refresh/disarm semantics as the REST surface (the switch is +transport-agnostic), driven through :class:`ReyaWsExecClient`. Gated on the +same env as tests/ws_exec/test_ws_exec.py: without `REYA_WS_EXEC_URL` + +SPOT_*_1 credentials the module collects-and-skips. + +The bad-payload error-envelope cases reuse the raw-WebSocket helpers from +tests/ws_exec/test_ws_exec.py — the high-level client rejects an out-of-range +timeoutMs locally (and never builds a tampered signature), so the negative +probes must go over a raw socket with a hand-built (correctly signed) payload. + +The fire test rests a real spot order and lets the WS-armed countdown +mass-cancel it, proving the arm is dispatched into the engine rather than +merely echoed. Engine-internal countdown choreography (refresh, disarm +prevents fire, account isolation, rearm) stays REST-only — see +tests/engine/test_cod_lifecycle.py. +""" + +from __future__ import annotations + +import asyncio +import os +import time +import uuid + +import pytest +import pytest_asyncio +from dotenv import load_dotenv + +from sdk.open_api.models.order import Order +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api import ReyaTradingClient +from sdk.reya_rest_api.config import TradingConfig +from sdk.reya_rest_api.models.orders import LimitOrderParameters +from sdk.reya_ws_exec import ReyaWsExecClient +from tests.helpers.ws_exec_harness import assert_per_op_error, raw_connect, raw_recv_until, raw_send_envelope + +load_dotenv() + +_REQUIRED_ENV = ( + "REYA_WS_EXEC_URL", + "SPOT_PRIVATE_KEY_1", + "SPOT_ACCOUNT_ID_1", + "SPOT_WALLET_ADDRESS_1", +) +_MISSING_ENV = [_k for _k in _REQUIRED_ENV if not os.environ.get(_k)] + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.cod, + pytest.mark.skipif( + bool(_MISSING_ENV), + reason="ws-exec COD tests need " + ", ".join(_REQUIRED_ENV) + "; missing: " + ", ".join(_MISSING_ENV), + ), +] + +TRIGGER_AT_WINDOW_MS = 2_000 + +SPOT_SYMBOL = "WETHRUSD" +# Far-out resting price on an ETH-priced book (same convention as +# tests/ws_exec/test_ws_exec.py): the GTC rests until cancelled or COD fires. +REST_PX = "1" +FIRE_TIMEOUT_MS = 5_000 +# The ME scans armed countdowns on a ~500ms tick; allow that plus clock skew +# and request latency before declaring a fire missed (mirrors +# tests/engine/test_cod_lifecycle.py). +FIRE_MARGIN_S = 3.0 + + +async def _wait_for_open_order(rest: ReyaTradingClient, order_id: str, timeout_s: float = 10.0) -> Order: + """Poll openOrders until `order_id` appears — the OrdersProvider cache + consumes the ME's Redis stream asynchronously w.r.t. the ws-exec ack.""" + deadline = time.time() + timeout_s + while time.time() < deadline: + order = next((o for o in await rest.get_open_orders() if o.order_id == order_id), None) + if order is not None: + return order + await asyncio.sleep(0.2) + raise AssertionError(f"Order {order_id} not visible via REST within {timeout_s}s") + + +async def _wait_until_no_open_orders(rest: ReyaTradingClient, timeout_s: float) -> None: + """Poll openOrders until the account has none — proves the fire emptied it.""" + deadline = time.time() + timeout_s + remaining: list[Order] = [] + while time.time() < deadline: + remaining = await rest.get_open_orders() + if not remaining: + return + await asyncio.sleep(0.5) + raise AssertionError( + f"Account still has {len(remaining)} open order(s) {timeout_s}s after arming: " + f"{[o.order_id for o in remaining]}" + ) + + +@pytest_asyncio.fixture(loop_scope="session", scope="module") +async def cod_ws_harness(): + """A started spot REST client + connected ws-exec client. + + Yields ``(rest, ws)``; disarms COD in teardown so an armed countdown + never leaks from a failed assertion onto the shared devnet account. + """ + config = TradingConfig.from_env_spot(account_number=1) + rest = ReyaTradingClient(config) + await rest.start() + ws = ReyaWsExecClient(rest_client=rest, ws_url=os.environ["REYA_WS_EXEC_URL"]) + await ws.connect() + try: + yield rest, ws + finally: + try: + await ws.cancel_all_after(timeout_ms=0) + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught # nosec B110 + pass # best-effort teardown disarm + await ws.close() + await rest.close() + + +async def test_ws_exec_arm_disarm(cod_ws_harness): # pylint: disable=redefined-outer-name + """Arm + disarm over ws-exec: triggerAt echoed on arm (ME clock, ±2s), + absent on disarm.""" + _rest, ws = cod_ws_harness + + sent_at_ms = time.time() * 1000 + armed = await ws.cancel_all_after(timeout_ms=30_000) + try: + assert armed.timeout_ms == 30_000 + assert armed.trigger_at is not None, f"Armed countdown must echo triggerAt: {armed}" + drift = armed.trigger_at - (sent_at_ms + 30_000) + assert abs(drift) <= TRIGGER_AT_WINDOW_MS, f"triggerAt drift {drift:+.0f}ms exceeds ±{TRIGGER_AT_WINDOW_MS}ms" + print(f" [ws-exec] armed OK triggerAt={armed.trigger_at}") + finally: + disarmed = await ws.cancel_all_after(timeout_ms=0) + + assert disarmed.timeout_ms == 0 + assert disarmed.trigger_at is None, f"Disarm must not echo a triggerAt: {disarmed}" + print(" [ws-exec] disarm OK (no triggerAt)") + + +async def test_ws_exec_arm_fires_cancels_resting_order(cod_ws_harness): # pylint: disable=redefined-outer-name + """A cancelAllAfter ARM sent over ws-exec drives the REAL engine countdown. + + Transport-layer concern: request mapping/dispatch WITH effect — the arm + must reach the matching engine's dead-man's-switch (not merely echo + triggerAt back), proven by a resting order actually being mass-cancelled + when the WS-armed countdown fires. The countdown's internal choreography + is pinned REST-side in tests/engine/test_cod_lifecycle.py. + """ + rest, ws = cod_ws_harness + + markets = {m.symbol: m for m in await rest.reference.get_spot_market_definitions()} + if SPOT_SYMBOL not in markets: + pytest.skip(f"{SPOT_SYMBOL} not found in /spotMarketDefinitions") + min_qty = str(markets[SPOT_SYMBOL].min_order_qty) + + create = await ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=REST_PX, + qty=min_qty, + time_in_force=TimeInForce.GTC, + ) + ) + assert create.order_id is not None + order_id = create.order_id + + fired = False + try: + await _wait_for_open_order(rest, order_id) + + armed = await ws.cancel_all_after(timeout_ms=FIRE_TIMEOUT_MS) + assert armed.timeout_ms == FIRE_TIMEOUT_MS + assert armed.trigger_at is not None, f"Armed countdown must echo triggerAt: {armed}" + + # timeout + ME scan granularity + clock-skew margin. + await _wait_until_no_open_orders(rest, timeout_s=FIRE_TIMEOUT_MS / 1000 + FIRE_MARGIN_S) + fired = True + print(f" [ws-exec] COD fired: order {order_id} cancelled by the WS-armed countdown") + + disarmed = await ws.cancel_all_after(timeout_ms=0) + assert disarmed.timeout_ms == 0 + assert disarmed.trigger_at is None, f"Post-fire disarm must not echo a triggerAt: {disarmed}" + finally: + if not fired: + # Failure path: disarm first so the countdown can't race the + # cleanup cancel, then clear the resting order. + try: + await ws.cancel_all_after(timeout_ms=0) + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught # nosec B110 + pass # best-effort failure-path disarm + try: + await ws.cancel_order(order_id=order_id, symbol=SPOT_SYMBOL, account_id=rest.config.account_id) + except Exception: # noqa: BLE001 # pylint: disable=broad-exception-caught # nosec B110 + pass # order may already be gone (late fire) — best-effort + + +async def test_ws_exec_out_of_range_timeout_error_envelope(cod_ws_harness): # pylint: disable=redefined-outer-name + """A correctly-signed cancelAllAfter with timeoutMs=1 (below the 5000ms + floor) comes back as a per-op error envelope with INPUT_VALIDATION_ERROR. + + Sent over a raw socket because the high-level client refuses to build the + payload (client guard pinned in tests/validation/test_client_guards.py). + """ + rest, _ws = cod_ws_harness + + nonce = rest.get_next_nonce() + deadline = int(time.time()) + 60 + payload = { + "accountId": rest.config.account_id, + "timeoutMs": 1, + "signature": rest.signature_generator.sign_cancel_all_after( + account_id=rest.config.account_id, + timeout_ms=1, + nonce=nonce, + deadline=deadline, + ), + "nonce": str(nonce), + "signerWallet": rest.signer_wallet_address, + "deadline": deadline, + } + + raw_ws = raw_connect(os.environ["REYA_WS_EXEC_URL"]) + try: + env_id = uuid.uuid4().hex[:12] + raw_send_envelope(raw_ws, "cancelAllAfter", env_id, payload) + resp = raw_recv_until(raw_ws, lambda f: f.get("id") == env_id and "ok" in f) + err = assert_per_op_error(resp, ("INPUT_VALIDATION_ERROR",), "cancelAllAfter timeoutMs=1") + print(f" [ws-exec] out-of-range timeoutMs rejected OK code={err.get('error')!r}") + finally: + raw_ws.close() + + +async def test_ws_exec_tampered_signature_error_envelope(cod_ws_harness): # pylint: disable=redefined-outer-name + """Sign timeoutMs=30000 but send timeoutMs=40000 — both in-range, so only + signature recovery can reject — and expect the per-op error envelope with + UNAUTHORIZED_SIGNATURE_ERROR. + + Transport-layer concern: signature-envelope mapping — ws-exec must route a + signature-recovery failure into the per-op error envelope (mirror of REST + tests/engine/test_cod_validation.py::test_tampered_signature_rejected), + and the rejected request must not arm anything. + """ + rest, ws = cod_ws_harness + + nonce = rest.get_next_nonce() + deadline = int(time.time()) + 60 + payload = { + "accountId": rest.config.account_id, + "timeoutMs": 40_000, + # Signed over 30_000 while the wire says 40_000: the recovered signer + # saw different bytes than the payload, so recovery diverges. + "signature": rest.signature_generator.sign_cancel_all_after( + account_id=rest.config.account_id, + timeout_ms=30_000, + nonce=nonce, + deadline=deadline, + ), + "nonce": str(nonce), + "signerWallet": rest.signer_wallet_address, + "deadline": deadline, + } + + raw_ws = raw_connect(os.environ["REYA_WS_EXEC_URL"]) + try: + env_id = uuid.uuid4().hex[:12] + raw_send_envelope(raw_ws, "cancelAllAfter", env_id, payload) + resp = raw_recv_until(raw_ws, lambda f: f.get("id") == env_id and "ok" in f) + err = assert_per_op_error(resp, ("UNAUTHORIZED_SIGNATURE_ERROR",), "cancelAllAfter tampered timeoutMs") + print(f" [ws-exec] tampered timeoutMs rejected OK code={err.get('error')!r}") + finally: + raw_ws.close() + + # Defensive: prove the rejected request armed nothing. + disarmed = await ws.cancel_all_after(timeout_ms=0) + assert disarmed.trigger_at is None, f"Rejected arm must not leave a countdown: {disarmed}" diff --git a/tests/test_orderbook/test_execution_busts.py b/tests/engine/test_execution_busts.py similarity index 80% rename from tests/test_orderbook/test_execution_busts.py rename to tests/engine/test_execution_busts.py index 55b06e2e..daf05dfd 100644 --- a/tests/test_orderbook/test_execution_busts.py +++ b/tests/engine/test_execution_busts.py @@ -16,6 +16,7 @@ from sdk.open_api.models.execution_bust import ExecutionBust from sdk.open_api.models.execution_bust_list import ExecutionBustList from tests.helpers import ReyaTester +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig @pytest.mark.asyncio @@ -33,21 +34,23 @@ async def test_wallet_execution_busts_endpoint_shape(reya_tester: ReyaTester) -> @pytest.mark.asyncio -@pytest.mark.parametrize( - "symbol", - ["ETHRUSD", "ETHRUSDPERP"], - ids=["spot", "perp"], -) -async def test_market_execution_busts_endpoint_shape(reya_tester: ReyaTester, symbol: str) -> None: - """``GET /v2/market/{symbol}/executionBusts`` returns a well-formed list for both market types.""" - if symbol not in reya_tester.client._symbol_to_market_id: # pylint: disable=protected-access - pytest.skip(f"{symbol} not enabled on this environment") - +async def test_market_execution_busts_endpoint_shape( + reya_tester: ReyaTester, + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, +) -> None: + """``GET /v2/market/{symbol}/executionBusts`` returns a well-formed list for both market types. + + The symbol comes from the env-resolved ``market_config`` (not a hardcoded + literal): a hardcoded "ETHRUSD" silently skipped the spot leg on every + devnet run because deployed spot symbols are W-prefixed (WETHRUSD). + """ + symbol = market_config.symbol bust_list = await reya_tester.client.markets.get_market_execution_busts(symbol=symbol) assert isinstance(bust_list, ExecutionBustList) for bust in bust_list.data: assert isinstance(bust, ExecutionBust) - assert bust.symbol == symbol + assert bust.symbol == symbol, f"[{market_type}] bust symbol mismatch" @pytest.mark.asyncio diff --git a/tests/engine/test_gtt_lifecycle.py b/tests/engine/test_gtt_lifecycle.py new file mode 100644 index 00000000..08e9e092 --- /dev/null +++ b/tests/engine/test_gtt_lifecycle.py @@ -0,0 +1,184 @@ +"""Good-Till-Time (GTT) lifecycle tests parametrized over [spot, perp] — live e2e. + +GTT rests like GTC but the matching engine auto-reaps it at ``expiresAfter`` +(GTC rests until cancelled; IOC never rests). These tests prove the full GTT +path on devnet: +- a GTT rests (OPEN), is reachable via REST carrying its ``expiresAfter``, and + is cancellable, +- a GTT with a near-future expiry is AUTO-CANCELLED by the reaper at + ``expiresAfter`` with no explicit cancel, +- a resting GTT can be modified to refresh its expiry (stays resting); a modify + dropping the expiry to 0 is rejected client-side (the GTC/GTT coupling), +- a resting GTT that gets filled SETTLES on-chain — the fill calldata + reproduces the signed ``timeInForce=GTT``, so settlement must NOT bust. + +``maker`` is the aggressor (the account whose order crosses); ``taker`` is the +resting counterparty — matching the ``settlement_probe`` convention. +""" + +from __future__ import annotations + +import time +from decimal import Decimal + +import pytest + +from sdk.open_api.models.order_status import OrderStatus +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api.models import LimitOrderParameters +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import full_state_modify_params +from tests.helpers.liquidity_detector import skip_if_external_config_liquidity +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig +from tests.helpers.order_lifecycle import rest_gtt, wait_for_order_fields +from tests.helpers.reya_tester import logger +from tests.helpers.settlement import SettlementProbe + +pytestmark = [pytest.mark.e2e, pytest.mark.gtt] + +# Comfortable lifetime for tests that must NOT expire mid-run (rest / modify / +# fill all complete in well under a second). +GTT_LIFETIME_S = 300 + + +@pytest.mark.asyncio +async def test_gtt_rests_open_and_cancellable( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """A GTT placed away from the touch rests (OPEN), is reachable via REST + carrying its ``expiresAfter``, and is cancellable.""" + expires_after = int(time.time()) + GTT_LIFETIME_S + order = await rest_gtt(maker, market_config, price_multiplier=0.5, expires_after=expires_after) + + assert order.status == OrderStatus.OPEN, f"[{market_type}] a GTT must rest OPEN, got {order.status}" + assert ( + int(order.expires_after or 0) == expires_after + ), f"[{market_type}] GTT must carry its expiresAfter: {order.expires_after} != {expires_after}" + + await maker.client.cancel_order(symbol=market_config.symbol, account_id=maker.account_id, order_id=order.order_id) + await maker.wait.for_order_state(order.order_id, OrderStatus.CANCELLED) + logger.info(f"[{market_type}] ✅ GTT rested OPEN with expiresAfter and was cancelled") + + +@pytest.mark.asyncio +async def test_gtt_reaped_at_expiry( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """A resting GTT is AUTO-CANCELLED by the matching engine's reaper at + ``expiresAfter`` — no explicit cancel. A short deadline + expiry (the + coupling requires ``expiresAfter`` strictly after the deadline) keeps the + reap inside the poll window.""" + now = int(time.time()) + deadline = now + 25 + expires_after = now + 35 # strictly after the deadline; reaped shortly after + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=str(market_config.price(0.5)), + qty=market_config.min_qty, + time_in_force=TimeInForce.GTT, + expires_after=expires_after, + deadline=deadline, + ) + order_id = await maker.orders.create_limit(params) + assert order_id is not None, f"[{market_type}] GTT creation must return an order_id" + await maker.wait.for_order_creation(order_id) + + resting = await wait_for_order_fields(maker, order_id) + assert resting.status == OrderStatus.OPEN, f"[{market_type}] GTT must rest before its expiry" + + # Do NOT cancel — the reaper must auto-cancel it at expiresAfter. + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED, timeout=90) + logger.info(f"[{market_type}] ✅ GTT auto-reaped at expiresAfter (no explicit cancel)") + + +@pytest.mark.asyncio +async def test_gtt_modify_refresh_expiry( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """A resting GTT can be modified to a NEW future ``expiresAfter`` (stays + resting with the refreshed lifetime); a modify dropping the expiry to 0 is + rejected client-side (a GTT must carry a lifetime — 0 would be GTC).""" + expires_after = int(time.time()) + GTT_LIFETIME_S + order = await rest_gtt(maker, market_config, price_multiplier=0.5, expires_after=expires_after) + + new_expires_after = int(time.time()) + GTT_LIFETIME_S + 120 + response = await maker.client.modify_order(full_state_modify_params(order, expires_after=new_expires_after)) + assert response.order_id == order.order_id, f"[{market_type}] orderId must be preserved through modify" + + refreshed = await wait_for_order_fields(maker, order.order_id, expires_after=new_expires_after) + assert refreshed.status == OrderStatus.OPEN, f"[{market_type}] GTT must stay resting after an expiry refresh" + + # Dropping the expiry to 0 on a resting GTT is rejected client-side before + # signing — that would turn it into a never-expiring (GTC) order. + with pytest.raises(ValueError, match="GTT orders require a non-zero expires_after"): + await maker.client.modify_order(full_state_modify_params(refreshed, expires_after=0)) + + await maker.client.cancel_order(symbol=market_config.symbol, account_id=maker.account_id, order_id=order.order_id) + await maker.wait.for_order_state(order.order_id, OrderStatus.CANCELLED) + logger.info(f"[{market_type}] ✅ GTT expiry refreshed via modify; drop-to-0 rejected client-side") + + +@pytest.mark.asyncio +@pytest.mark.maker_taker +@pytest.mark.usefixtures("settlement_cleanup_guard") +async def test_gtt_resting_fill_settles_on_chain( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, + settlement_probe: SettlementProbe, +) -> None: + """A resting GTT that gets filled settles on-chain: the fill calldata + reproduces the signed ``timeInForce=GTT``, so settlement must NOT bust + InvalidSignature. The resting counterparty (``taker``) rests a GTT SELL; + the aggressor (``maker``) crosses it with an IOC BUY; settlement lands + (spot balance deltas / perp signed-position deltas) with no execution + busts (asserted session-wide by ``execution_busts_guard``).""" + await skip_if_external_config_liquidity(market_config, maker, "Engineered cross needs an empty book.") + qty = market_config.min_qty + expires_after = int(time.time()) + GTT_LIFETIME_S + + if market_type == "perp": + # Cross at the CURRENT mark (re-fetched), not the stale session-start + # config price: over a long suite run the oracle drifts, and a cross far + # from the live mark gets blocked by the perp execution band (the + # conftest perp-cross helpers re-fetch the current oracle for the same + # reason). The perp symbol IS its own oracle symbol. + mark = Decimal(str(await maker.data.current_price(market_config.symbol))) + cross_px = str(mark.quantize(Decimal("0.01"))) + else: + # Spot prices come from the perp oracle (not the spot symbol) and there + # is no execution band / dynamic depth, so the config price is reliable. + cross_px = str(market_config.price(0.99)) + + await settlement_probe.capture_baseline() + + await rest_gtt(taker, market_config, price=cross_px, expires_after=expires_after, is_buy=False) + buyer_order_id = await maker.orders.create_limit( + LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=cross_px, + qty=qty, + time_in_force=TimeInForce.IOC, + ) + ) + assert buyer_order_id is not None, f"[{market_type}] aggressor IOC must return an order_id" + + # The authoritative proof that the resting GTT FILLED and SETTLED on-chain is + # the position/balance delta (via REST), which settlement_probe polls and + # which times out if the two-party cross never landed. We deliberately do NOT + # gate on the resting order's WS status first: the WS order-change store can + # lag or go stale over a long session (a false "not found"), whereas the + # on-chain delta is both stronger (settlement actually moved funds) and + # market-correct (the probe requires BOTH our accounts to have moved ±qty, so + # it only passes if our two orders crossed EACH OTHER, not algorithmic depth). + await settlement_probe.assert_settled(qty=qty, price=cross_px, timeout_s=25) + logger.info(f"[{market_type}] ✅ a resting GTT filled and settled on-chain (no bust)") diff --git a/tests/engine/test_gtt_ws_exec.py b/tests/engine/test_gtt_ws_exec.py new file mode 100644 index 00000000..cb1eb31f --- /dev/null +++ b/tests/engine/test_gtt_ws_exec.py @@ -0,0 +1,191 @@ +"""Good-Till-Time (GTT) over ws-exec — live e2e. + +Engine-side GTT semantics (resting, reaper-cancel, modify-refresh, settlement) +are proven transport-independently via REST in tests/engine/test_gtt_lifecycle.py +(parametrized [spot, perp]). This module pins the ws-exec transport's OWN GTT +surface, spot-pinned like the modify / post-only / cod ws-exec suites: + +* request payload mapping + lifetime fidelity — a GTT createOrder over ws-exec + reaches the real engine and rests OPEN, and its non-zero ``expiresAfter`` + round-trips to the resting order (asserted via REST openOrders read-back, the + only fidelity oracle — the WS CreateOrderResponse carries no expiresAfter + echo); +* full ws-exec lifetime — a GTT created over ws-exec with a near-future expiry + is auto-reaped by the matching engine at ``expiresAfter`` with no explicit + cancel, proving the ws-exec → ME → reaper path end-to-end. + +ws-exec routes createOrder through the shared TIF-agnostic handler (it does not +have the REST dispatcher's order-class allow-list), so this also guards that a +GTT is never misrouted on the ws-exec transport. Gated on the same env as +tests/ws_exec/test_ws_exec.py. +""" + +from __future__ import annotations + +import asyncio +import os +import time +from dataclasses import dataclass + +import pytest +import pytest_asyncio +from dotenv import load_dotenv + +from sdk.async_exec_api.order_status import OrderStatus +from sdk.open_api.models.order import Order +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api import ReyaTradingClient +from sdk.reya_rest_api.config import TradingConfig +from sdk.reya_rest_api.models.orders import LimitOrderParameters +from sdk.reya_ws_exec import ReyaWsExecClient + +load_dotenv() + +_REQUIRED_ENV = ( + "REYA_WS_EXEC_URL", + "SPOT_PRIVATE_KEY_1", + "SPOT_ACCOUNT_ID_1", + "SPOT_WALLET_ADDRESS_1", +) +_MISSING_ENV = [_k for _k in _REQUIRED_ENV if not os.environ.get(_k)] + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.gtt, + pytest.mark.skipif( + bool(_MISSING_ENV), + reason="ws-exec GTT tests need " + ", ".join(_REQUIRED_ENV) + "; missing: " + ", ".join(_MISSING_ENV), + ), +] + +SPOT_SYMBOL = "WETHRUSD" +# Far-out resting buy on an ETH-priced book (same convention as the other +# ws-exec suites): the GTT rests until it expires or is cancelled. +REST_BUY_PX = "1" +# Comfortable lifetime for the rest/read-back test (completes in well under a +# second, so it never expires mid-test). +GTT_LIFETIME_S = 300 + + +@dataclass +class _GttWsHarness: + """Shared, module-scoped state for the GTT ws-exec flows.""" + + rest: ReyaTradingClient + ws: ReyaWsExecClient + min_qty: str + + +@pytest_asyncio.fixture(loop_scope="session", scope="module") +async def gtt_ws_harness(): + """A started spot REST client + connected ws-exec client + market metadata. + + Mirrors post_only_ws_harness / modify_ws_harness: everything after start() + runs inside the try so a skip (or a connect failure) never leaks the + aiohttp session. + """ + config = TradingConfig.from_env_spot(account_number=1) + rest = ReyaTradingClient(config) + await rest.start() + ws: ReyaWsExecClient | None = None + try: + markets = {m.symbol: m for m in await rest.reference.get_spot_market_definitions()} + if SPOT_SYMBOL not in markets: + pytest.skip(f"{SPOT_SYMBOL} not found in /spotMarketDefinitions") + market = markets[SPOT_SYMBOL] + ws = ReyaWsExecClient(rest_client=rest, ws_url=os.environ["REYA_WS_EXEC_URL"]) + await ws.connect() + yield _GttWsHarness(rest=rest, ws=ws, min_qty=str(market.min_order_qty)) + finally: + if ws is not None: + await ws.close() + await rest.close() + + +async def _wait_for_open_order(rest: ReyaTradingClient, order_id: str, timeout_s: float = 10.0) -> Order: + """Poll openOrders until `order_id` appears — the OrdersProvider cache + consumes the ME's Redis stream asynchronously w.r.t. the ws-exec ack.""" + deadline = time.time() + timeout_s + while time.time() < deadline: + open_orders = await rest.get_open_orders() + order = next((o for o in open_orders if o.order_id == order_id), None) + if order is not None: + return order + await asyncio.sleep(0.2) + raise AssertionError(f"Order {order_id} not visible via REST within {timeout_s}s") + + +async def _wait_for_order_gone(rest: ReyaTradingClient, order_id: str, timeout_s: float = 90.0) -> None: + """Poll openOrders until `order_id` is no longer resting — a reaped GTT is + cancelled and drops out of openOrders.""" + deadline = time.time() + timeout_s + while time.time() < deadline: + open_ids = {o.order_id for o in await rest.get_open_orders()} + if order_id not in open_ids: + return + await asyncio.sleep(0.5) + raise AssertionError(f"Order {order_id} still resting after {timeout_s}s; reaper did not cancel it") + + +async def test_ws_exec_gtt_rests_and_reads_back(gtt_ws_harness): # pylint: disable=redefined-outer-name + """Transport concern: request payload mapping + lifetime fidelity. A GTT + createOrder over ws-exec reaches the real engine and rests OPEN; its + non-zero expiresAfter round-trips to the resting order via REST openOrders + read-back (WsCreateOrderResponse carries no expiresAfter echo).""" + h = gtt_ws_harness + expires_after = int(time.time()) + GTT_LIFETIME_S + + create = await h.ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=REST_BUY_PX, + qty=h.min_qty, + time_in_force=TimeInForce.GTT, + expires_after=expires_after, + ) + ) + order_id = create.order_id + assert order_id is not None, f"GTT createOrder OK but missing orderId: {create}" + assert create.status == OrderStatus.OPEN, f"GTT away from the touch must rest OPEN: {create.status}" + + try: + order = await _wait_for_open_order(h.rest, order_id) + assert ( + int(order.expires_after or 0) == expires_after + ), f"GTT expiresAfter must read back via REST: {order.expires_after} != {expires_after}" + print(f" [ws-exec] GTT rested OPEN with expiresAfter={expires_after} orderId={order_id}") + finally: + await h.ws.cancel_order(order_id=order_id, symbol=SPOT_SYMBOL, account_id=h.rest.config.account_id) + + +async def test_ws_exec_gtt_reaped_at_expiry(gtt_ws_harness): # pylint: disable=redefined-outer-name + """Full ws-exec lifetime: a GTT created over ws-exec with a near-future + expiry is auto-reaped by the matching engine at expiresAfter — no explicit + cancel — proving the ws-exec → ME → reaper path end-to-end. Short + deadline + expiry (expiresAfter must be strictly after the deadline) keeps + the reap inside the poll window.""" + h = gtt_ws_harness + now = int(time.time()) + deadline = now + 25 + expires_after = now + 35 # strictly after the deadline; reaped shortly after + + create = await h.ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=REST_BUY_PX, + qty=h.min_qty, + time_in_force=TimeInForce.GTT, + expires_after=expires_after, + deadline=deadline, + ) + ) + order_id = create.order_id + assert order_id is not None, f"GTT createOrder OK but missing orderId: {create}" + + resting = await _wait_for_open_order(h.rest, order_id) + assert resting.status is not None # rests before expiry + # Do NOT cancel — the reaper must auto-cancel it at expiresAfter. + await _wait_for_order_gone(h.rest, order_id) + print(f" [ws-exec] GTT auto-reaped at expiresAfter (no explicit cancel) orderId={order_id}") diff --git a/tests/test_orderbook/test_ioc_orders.py b/tests/engine/test_ioc_orders.py similarity index 68% rename from tests/test_orderbook/test_ioc_orders.py rename to tests/engine/test_ioc_orders.py index 93803804..d84f78e2 100644 --- a/tests/test_orderbook/test_ioc_orders.py +++ b/tests/engine/test_ioc_orders.py @@ -7,7 +7,7 @@ These tests focus on order-lifecycle behaviour (fill / no-fill / partial-fill). Asset-balance verification (spot-only — perp settles into positions, not asset -balances) lives in ``tests/test_spot/test_ioc_orders.py`` and ``test_balance_verification.py``. +balances) lives in ``tests/spot/test_ioc_orders.py`` and ``test_balance_verification.py``. """ from __future__ import annotations @@ -20,8 +20,7 @@ from sdk.open_api.models.time_in_force import TimeInForce from sdk.reya_rest_api.models import LimitOrderParameters from tests.helpers import ReyaTester -from tests.test_orderbook.conftest import PerpTestConfig -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig @pytest.mark.asyncio @@ -152,3 +151,66 @@ async def test_ioc_partial_fill_when_maker_smaller( open_orders_taker = await taker.client.get_open_orders() open_ids_taker = {o.order_id for o in open_orders_taker if o.symbol == market_config.symbol} assert taker_order_id not in open_ids_taker, f"[{market_type}] IOC remainder must not rest" + + +@pytest.mark.asyncio +async def test_ioc_crosses_multiple_price_levels( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """One IOC sized for two maker levels sweeps both in a single taker order. + + Previously pinned only on spot (tests/spot/test_ioc_orders.py) — on + perp a multi-level sweep settles multiple fills from one taker order + (batch fill path), which was untested. + """ + await market_config.refresh_order_book(taker.data) + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + + if market_config.has_any_external_liquidity: + pytest.skip("external liquidity present — multi-level sweep requires controlled book") + + lower_px = str(market_config.price(0.97)) + higher_px = str(market_config.price(0.99)) + qty_per_level = market_config.min_qty + sweep_qty = str(Decimal(qty_per_level) * 2) + + level_ids = [] + for px in (lower_px, higher_px): + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=px, + qty=qty_per_level, + time_in_force=TimeInForce.GTC, + ) + order_id = await maker.orders.create_limit(params) + assert order_id is not None, f"[{market_type}] expected order_id at {px}" + await maker.wait.for_order_creation(order_id) + level_ids.append(order_id) + + # Sell limit at the LOWER price crosses both bid levels; qty covers both. + taker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=False, + limit_px=lower_px, + qty=sweep_qty, + time_in_force=TimeInForce.IOC, + ) + taker_order_id = await taker.orders.create_limit(taker_params) + assert taker_order_id is not None + + await asyncio.sleep(0.5) + + # Both maker levels consumed; the IOC did not rest. + open_orders = await maker.client.get_open_orders() + open_ids = {o.order_id for o in open_orders if o.symbol == market_config.symbol} + for order_id in level_ids: + assert order_id not in open_ids, f"[{market_type}] maker level {order_id} should be swept" + + open_orders_taker = await taker.client.get_open_orders() + open_ids_taker = {o.order_id for o in open_orders_taker if o.symbol == market_config.symbol} + assert taker_order_id not in open_ids_taker, f"[{market_type}] IOC must not rest after the sweep" diff --git a/tests/engine/test_limit_orders.py b/tests/engine/test_limit_orders.py new file mode 100644 index 00000000..0f3298cf --- /dev/null +++ b/tests/engine/test_limit_orders.py @@ -0,0 +1,225 @@ +""" +Shared limit-order lifecycle tests parametrized over [spot, perp]. + +Under v2.3.0 both market types route through the same matching engine, so a +single test body verifies the place→fill→position-or-balance flow for both. +Spot-only behaviours (auto-exchange busts) live in tests/spot/; perp-only +behaviours (triggers, funding, positions) live in tests/perp/. +""" + +from __future__ import annotations + +import asyncio +from decimal import Decimal + +import pytest + +from sdk.open_api.models.order_status import OrderStatus +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api.models import LimitOrderParameters +from tests.helpers import ReyaTester +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig + + +async def _rest_gtc_buy( + maker: ReyaTester, market_config: SpotTestConfig | PerpTestConfig, px: str, market_type: str +) -> str: + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=px, + qty=market_config.min_qty, + time_in_force=TimeInForce.GTC, + ) + order_id = await maker.orders.create_limit(params) + assert order_id is not None, f"[{market_type}] expected order_id" + await maker.wait.for_order_creation(order_id) + return order_id + + +async def _ioc_sell(taker: ReyaTester, market_config: SpotTestConfig | PerpTestConfig, px: str, qty: str) -> None: + await taker.orders.create_limit( + LimitOrderParameters( + symbol=market_config.symbol, + is_buy=False, + limit_px=px, + qty=qty, + time_in_force=TimeInForce.IOC, + ) + ) + + +@pytest.mark.asyncio +async def test_gtc_place_and_cancel( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """A GTC limit order placed far from market is reachable via REST + cancellable.""" + safe_buy_px = str(round(market_config.oracle_price * 0.5, 2)) + + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=safe_buy_px, + qty=market_config.min_qty, + time_in_force=TimeInForce.GTC, + ) + response = await maker.client.create_limit_order(params) + assert response.order_id is not None, f"[{market_type}] no order_id in response" + + open_order = await maker.data.open_order(response.order_id) + assert open_order is not None, f"[{market_type}] order not visible via REST after placement" + assert open_order.status == OrderStatus.OPEN + + cancel_response = await maker.client.cancel_order( + symbol=market_config.symbol, + account_id=maker.account_id, + order_id=response.order_id, + ) + assert cancel_response is not None + + await maker.wait.for_order_state(response.order_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_gtc_price_time_priority_fifo( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """Two same-price GTC buys fill in arrival order: a taker sell sized to + exactly one order fills the FIRST one; the second keeps resting. + + Previously pinned only on spot (tests/spot/test_gtc_orders.py) — + the perp book's price-time ordering was asserted nowhere e2e. + """ + await market_config.refresh_order_book(taker.data) + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + if market_config.has_any_external_liquidity: + pytest.skip("external liquidity present — the taker could match externally instead of our makers") + + level_px = str(market_config.price(0.99)) + first_order_id = await _rest_gtc_buy(maker, market_config, level_px, market_type) + await asyncio.sleep(0.05) + second_order_id = await _rest_gtc_buy(maker, market_config, level_px, market_type) + + await _ioc_sell(taker, market_config, level_px, market_config.min_qty) + + await maker.wait.for_order_state(first_order_id, OrderStatus.FILLED, timeout=5) + open_orders = await maker.client.get_open_orders() + open_ids = {o.order_id for o in open_orders} + assert second_order_id in open_ids, f"[{market_type}] second same-price order must keep resting (FIFO)" + + await maker.client.cancel_order(symbol=market_config.symbol, account_id=maker.account_id, order_id=second_order_id) + await maker.wait.for_order_state(second_order_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_gtc_best_price_filled_first( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """With bids at two levels, a crossing sell fills the BETTER (higher) bid + first; the worse level keeps resting.""" + await market_config.refresh_order_book(taker.data) + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + if market_config.has_any_external_liquidity: + pytest.skip("external liquidity present — the taker could match externally instead of our makers") + + worse_px = str(market_config.price(0.97)) + better_px = str(market_config.price(0.99)) + worse_order_id = await _rest_gtc_buy(maker, market_config, worse_px, market_type) + better_order_id = await _rest_gtc_buy(maker, market_config, better_px, market_type) + + # Sell limit at the worse price crosses BOTH levels; qty covers only one. + await _ioc_sell(taker, market_config, worse_px, market_config.min_qty) + + await maker.wait.for_order_state(better_order_id, OrderStatus.FILLED, timeout=5) + open_orders = await maker.client.get_open_orders() + open_ids = {o.order_id for o in open_orders} + assert worse_order_id in open_ids, f"[{market_type}] worse-priced order must keep resting (best price first)" + + await maker.client.cancel_order(symbol=market_config.symbol, account_id=maker.account_id, order_id=worse_order_id) + await maker.wait.for_order_state(worse_order_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_mass_cancel_clears_open_orders( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """Mass-cancel removes all open orders on a symbol (works on both spot and perp under v2.3.0).""" + safe_buy_px = str(round(market_config.oracle_price * 0.5, 2)) + + placed_ids = [] + for _ in range(2): + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=safe_buy_px, + qty=market_config.min_qty, + time_in_force=TimeInForce.GTC, + ) + response = await maker.client.create_limit_order(params) + assert response.order_id is not None + placed_ids.append(response.order_id) + + await maker.client.mass_cancel( + symbol=market_config.symbol, + account_id=maker.account_id, + ) + + for order_id in placed_ids: + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED, timeout=10) + # market_type is consumed by the parametrization; tag log lines so failures are easier to triage. + _ = market_type + + +@pytest.mark.asyncio +async def test_gtc_partial_fill_remainder_rests( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """A crossing GTC larger than the resting maker fills partially and the + remainder RESTS on the book (unlike IOC, which drops it). + + Moved from tests/spot/test_gtc_orders.py and parametrized — the + GTC-remainder-rests path was previously untested on perp. + """ + await market_config.refresh_order_book(taker.data) + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + if market_config.has_any_external_liquidity: + pytest.skip("external liquidity present — the taker could match externally instead of our maker") + + cross_px = str(market_config.price(0.99)) + maker_order_id = await _rest_gtc_buy(maker, market_config, cross_px, market_type) + + taker_qty = str(Decimal(market_config.min_qty) * 2) + taker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=False, + limit_px=cross_px, + qty=taker_qty, + time_in_force=TimeInForce.GTC, + ) + taker_order_id = await taker.orders.create_limit(taker_params) + assert taker_order_id is not None + + await maker.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + + open_orders = await taker.client.get_open_orders() + taker_open = [o for o in open_orders if o.order_id == taker_order_id] + assert len(taker_open) == 1, f"[{market_type}] GTC remainder must rest on the book" + + await taker.client.cancel_order(symbol=market_config.symbol, account_id=taker.account_id, order_id=taker_order_id) + await taker.wait.for_order_state(taker_order_id, OrderStatus.CANCELLED) diff --git a/tests/test_orderbook/test_maker_taker_matching.py b/tests/engine/test_maker_taker_matching.py similarity index 86% rename from tests/test_orderbook/test_maker_taker_matching.py rename to tests/engine/test_maker_taker_matching.py index 5524f778..0a0e01e5 100644 --- a/tests/test_orderbook/test_maker_taker_matching.py +++ b/tests/engine/test_maker_taker_matching.py @@ -6,9 +6,8 @@ WebSocket. Asset-balance accounting is spot-specific (perp markets settle to positions, -not asset balances) and lives in ``tests/test_spot/test_maker_taker_matching.py``; -those assertions (``verify_spot_trade_balance_changes``) need both base and -quote asset deltas, which only make sense on spot. +not asset balances) and lives in ``tests/spot/test_balance_verification.py`` +(exact per-side deltas + maker/taker conservation). """ from __future__ import annotations @@ -22,9 +21,8 @@ from sdk.open_api.models.time_in_force import TimeInForce from sdk.reya_rest_api.models import LimitOrderParameters from tests.helpers import ReyaTester +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig from tests.helpers.reya_tester import logger -from tests.test_orderbook.conftest import PerpTestConfig -from tests.test_spot.spot_config import SpotTestConfig @pytest.mark.asyncio @@ -95,10 +93,18 @@ async def test_maker_taker_match_e2e( open_ids = {o.order_id for o in open_orders if o.symbol == market_config.symbol} assert maker_order_id not in open_ids, f"[{market_type}] maker must not remain OPEN after match" - # Step 5: WS order-changes event for maker. + # Step 5: WS order-changes event for maker — and when the maker reached + # FILLED, the event must carry that terminal status (folded from + # test_spot_ws_order_changes_on_fill when it was retired as a duplicate). assert maker_order_id in maker.ws.order_changes, ( f"[{market_type}] expected maker order in WS order_changes; " f"got: {list(maker.ws.order_changes.keys())[:5]}" ) + ws_event = maker.ws.order_changes[maker_order_id] + ws_status = ws_event.status.value if hasattr(ws_event.status, "value") else str(ws_event.status) + assert ws_status in ( + "FILLED", + "PARTIALLY_FILLED", + ), f"[{market_type}] maker WS event must carry the fill status, got {ws_status}" # Step 6: WS execution event for taker — perp goes via perp_executions, spot via spot_executions. if market_type == "spot": diff --git a/tests/engine/test_modify_execution.py b/tests/engine/test_modify_execution.py new file mode 100644 index 00000000..8958ad4c --- /dev/null +++ b/tests/engine/test_modify_execution.py @@ -0,0 +1,205 @@ +""" +modifyOrder execution-semantics tests parametrized over [spot, perp] — live e2e. + +- a non-post-only modify whose new limitPx crosses executes IMMEDIATELY + (response FILLED + execQty, the execution carries BOTH orderIds, settlement + lands — exact zero-fee balance deltas on spot / signed ±qty position deltas + on perp, via the injected ``settlement_probe`` — and NO execution busts + appear), +- a post-only modify that WOULD cross is rejected with + POST_ONLY_WOULD_CROSS_ERROR and the resting order is left untouched, +- ``qty`` is the TOTAL order quantity and must exceed ``cumQty`` + (MODIFY_QTY_BELOW_FILLED_ERROR otherwise). + +``maker`` is the aggressor (the account whose resting buy is modified up to +cross); ``taker`` is the resting counterparty (the ask). All tests need an +empty book — external liquidity would absorb or front-run the engineered +crosses. Fill cleanup: spot via ``spot_balance_guard``, perp via +``perp_baseline_restore`` — both wired by the autouse ``_settlement_cleanup``. +""" + +from decimal import Decimal + +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.builders.order_builder import full_state_modify_params +from tests.helpers.liquidity_detector import skip_if_external_config_liquidity +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig +from tests.helpers.order_lifecycle import assert_px_qty, rest_gtc, wait_for_order_fields, wait_for_taker_execution +from tests.helpers.reya_tester import logger +from tests.helpers.settlement import SettlementProbe + +pytestmark = [pytest.mark.e2e, pytest.mark.modify, pytest.mark.maker_taker] + +EXECUTION_REASON = "Engineered crosses need a controlled (empty) book." + + +@pytest.fixture(autouse=True) +def _settlement_cleanup(settlement_cleanup_guard): # pylint: disable=unused-argument + """Every test in this module rests orders and two of three produce fills, + so wire the per-market settlement cleanup (spot balance guard / perp + baseline restore).""" + yield + + +@pytest.mark.asyncio +async def test_crossing_modify_executes( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, + settlement_probe: SettlementProbe, +) -> None: + """Modifying a resting buy's px up to the counterparty's ask executes + immediately: response FILLED with execQty, the execution carries BOTH + orderIds (modified order as taker, counterparty as maker), settlement lands + (balances on spot / positions on perp), and NO execution busts appear.""" + await skip_if_external_config_liquidity(market_config, maker, EXECUTION_REASON) + qty = market_config.min_qty + cross_px = str(market_config.price(1.01)) + + await settlement_probe.capture_baseline() + buyer_busts_before = await maker.data.execution_busts() + seller_busts_before = await taker.data.execution_busts() + + buyer_order = await rest_gtc(maker, market_config, price_multiplier=0.96, is_buy=True) + seller_order = await rest_gtc(taker, market_config, price_multiplier=1.01, is_buy=False) + + response = await maker.client.modify_order(full_state_modify_params(buyer_order, limit_px=cross_px)) + assert response.order_id == buyer_order.order_id, "orderId must be preserved through a crossing modify" + assert response.status == OrderStatus.FILLED, f"[{market_type}] equal-sized cross must fully fill: {response}" + assert response.exec_qty is not None and Decimal(response.exec_qty) == Decimal( + qty + ), f"[{market_type}] execQty must report the crossed quantity: {response}" + logger.info(f"[{market_type}] ✅ crossing modify executed: {response.order_id} execQty={response.exec_qty}") + + execution = await wait_for_taker_execution(maker, market_type, buyer_order.order_id) + assert str(execution.maker_order_id) == str( + seller_order.order_id + ), f"Execution maker {execution.maker_order_id} != resting counterparty {seller_order.order_id}" + assert Decimal(execution.qty) == Decimal(qty), f"Execution qty {execution.qty} != {qty}" + assert Decimal(execution.price) == Decimal( + cross_px + ), f"Execution must print at the resting ask {cross_px}, got {execution.price}" + logger.info(f"[{market_type}] ✅ execution carries both orderIds at the resting px") + + await taker.wait.for_order_state(seller_order.order_id, OrderStatus.FILLED, timeout=5) + + # Settlement proof — spot: exact zero-fee balance deltas; perp: ±qty signed + # position deltas. The probe encapsulates the only market-divergent assertion. + await settlement_probe.assert_settled(qty=qty, price=cross_px) + logger.info(f"[{market_type}] ✅ settlement landed for both accounts") + + # Settlement landed → the executionBusts streams must NOT have grown + # (a bust = settlement failure). + buyer_busts_after = await maker.data.execution_busts() + seller_busts_after = await taker.data.execution_busts() + assert len(buyer_busts_after) == len( + buyer_busts_before + ), f"Buyer wallet gained execution busts: {len(buyer_busts_before)} -> {len(buyer_busts_after)}" + assert len(seller_busts_after) == len( + seller_busts_before + ), f"Seller wallet gained execution busts: {len(seller_busts_before)} -> {len(seller_busts_after)}" + involved_order_ids = {str(buyer_order.order_id), str(seller_order.order_id)} + busted = [ + bust + for bust in buyer_busts_after + seller_busts_after + if str(bust.order_id) in involved_order_ids or str(bust.maker_order_id) in involved_order_ids + ] + assert not busted, f"Execution busts reference the crossing-modify orders: {busted}" + logger.info(f"[{market_type}] ✅ executionBusts empty for both wallets after the crossing modify") + + await maker.check.no_open_orders() + await taker.check.no_open_orders() + + +@pytest.mark.asyncio +async def test_post_only_modify_would_cross_rejected( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """A would-cross modify with post_only=True is rejected with + POST_ONLY_WOULD_CROSS_ERROR and the resting order keeps its old px/qty + (priority intact).""" + await skip_if_external_config_liquidity(market_config, maker, EXECUTION_REASON) + + buyer_order = await rest_gtc(maker, market_config, price_multiplier=0.96, is_buy=True) + seller_order = await rest_gtc(taker, market_config, price_multiplier=1.01, is_buy=False) + original_px = buyer_order.limit_px + original_qty = buyer_order.qty + assert original_px is not None and original_qty is not None + cross_px = str(market_config.price(1.01)) + + try: + with pytest.raises(ApiException) as exc_info: + await maker.client.modify_order(full_state_modify_params(buyer_order, limit_px=cross_px, post_only=True)) + error_msg = str(exc_info.value) + assert ( + "POST_ONLY_WOULD_CROSS_ERROR" in error_msg + ), f"[{market_type}] expected POST_ONLY_WOULD_CROSS_ERROR, got: {error_msg[:200]}" + logger.info(f"[{market_type}] ✅ post-only would-cross modify rejected") + + untouched = await maker.data.open_order(buyer_order.order_id) + assert untouched is not None, "Rejected modify must leave the order resting" + assert_px_qty(untouched, expected_px=original_px, expected_qty=original_qty) + assert not untouched.post_only, "Rejected modify must not flip postOnly" + logger.info(f"[{market_type}] ✅ resting order untouched after the rejection") + finally: + await maker.orders.cancel( + order_id=buyer_order.order_id, symbol=market_config.symbol, account_id=maker.account_id + ) + await taker.orders.cancel( + order_id=seller_order.order_id, symbol=market_config.symbol, account_id=taker.account_id + ) + + +@pytest.mark.asyncio +async def test_qty_below_filled_rejected( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """Engineer a partial fill, then modify the TOTAL qty down to cumQty — + rejected with MODIFY_QTY_BELOW_FILLED_ERROR (qty must be STRICTLY greater + than the filled amount). Exercises cumQty accounting on a partially-filled + order on both markets.""" + await skip_if_external_config_liquidity(market_config, maker, EXECUTION_REASON) + min_qty = market_config.min_qty + double_min_qty = str(Decimal(min_qty) * 2) + queue_px = str(market_config.price(0.99)) + + maker_order = await rest_gtc(maker, market_config, price_multiplier=0.99, qty=double_min_qty, is_buy=True) + + try: + ioc = OrderBuilder().symbol(market_config.symbol).sell().price(queue_px).qty(min_qty).ioc().build() + taker_order_id = await taker.orders.create_limit(ioc) + assert taker_order_id is not None + await wait_for_taker_execution(taker, market_type, taker_order_id) + + # cumQty propagates through the OrdersProvider cache — poll for it. + partially_filled = await wait_for_order_fields(maker, maker_order.order_id, cum_qty=min_qty) + logger.info( + f"[{market_type}] maker partially filled: cumQty={partially_filled.cum_qty} of {partially_filled.qty}" + ) + + # TOTAL qty == cumQty (≤ filled) must be rejected. + with pytest.raises(ApiException) as exc_info: + await maker.client.modify_order(full_state_modify_params(partially_filled, qty=min_qty)) + error_msg = str(exc_info.value) + assert ( + "MODIFY_QTY_BELOW_FILLED_ERROR" in error_msg + ), f"[{market_type}] expected MODIFY_QTY_BELOW_FILLED_ERROR, got: {error_msg[:200]}" + logger.info(f"[{market_type}] ✅ qty ≤ cumQty rejected with MODIFY_QTY_BELOW_FILLED_ERROR") + + still_open = await maker.data.open_order(maker_order.order_id) + assert still_open is not None, "Rejected modify must leave the remainder resting" + finally: + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) diff --git a/tests/engine/test_modify_happy.py b/tests/engine/test_modify_happy.py new file mode 100644 index 00000000..42d96fe8 --- /dev/null +++ b/tests/engine/test_modify_happy.py @@ -0,0 +1,157 @@ +""" +modifyOrder happy-path tests parametrized over [spot, perp] — live e2e. + +In-place modification of a resting GTC: the order keeps its `orderId`, the +four modifiable fields (`limitPx`, `qty`, `postOnly`, `expiresAfter`) carry +the COMPLETE post-modify state, and `full_state_modify_params` restates +everything from the fetched Order so each test only spells out what it +changes. Post-modify read-backs poll openOrders (the OrdersProvider cache +consumes the ME's Redis stream asynchronously w.r.t. the modify response) and +assert a fresh `lastUpdateAt` plus the WS `orderChange` carrying the SAME +orderId with the new fields. No fills occur (the orders rest far from the +book), so these run on a single account (`maker`) per market. + +expiresAfter is TIF-bound: only GTT orders carry a lifetime. These happy-path +modifies all run on a resting GTC, so the flags-only test pins the (still-valid) +rule that a NON-GTT order's expiresAfter must be 0 — modifying a resting GTC to a +non-zero expiresAfter is rejected (INPUT_VALIDATION_ERROR, order untouched). GTT +creation IS supported by the SDK now (shipped in 98c10c2); the positive GTT +modify path (0→future→0 on a resting GTT) is intentionally DEFERRED until the +GTT matching-engine is on devnet1. +""" + +import time +from decimal import Decimal + +import pytest + +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import full_state_modify_params +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig +from tests.helpers.order_lifecycle import rest_gtc, wait_for_order_fields, wait_for_ws_order_change +from tests.helpers.reya_tester import logger + +pytestmark = [pytest.mark.e2e, pytest.mark.modify] + + +@pytest.mark.asyncio +async def test_modify_px_qty_happy( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester +) -> None: + """Modify px+qty of a resting GTC: orderId preserved, openOrders reflects + the new values with a fresh lastUpdateAt, and the WS orderChange carries + the SAME orderId with the new fields.""" + order = await rest_gtc(maker, market_config, price_multiplier=0.50) + order_id = order.order_id + + new_px = str(market_config.price(0.52)) + new_qty = str(Decimal(market_config.min_qty) * 2) + response = await maker.client.modify_order(full_state_modify_params(order, limit_px=new_px, qty=new_qty)) + + assert response.order_id == order_id, f"orderId must be preserved: {response.order_id} != {order_id}" + assert response.status == OrderStatus.OPEN, f"[{market_type}] non-crossing modify must stay OPEN: {response}" + + modified = await wait_for_order_fields(maker, order_id, limit_px=new_px, qty=new_qty) + assert modified.last_update_at > order.last_update_at, ( + f"lastUpdateAt must be refreshed by the modify: " + f"{modified.last_update_at} <= pre-modify {order.last_update_at}" + ) + logger.info(f"[{market_type}] ✅ modify px+qty OK: {order_id} -> px={new_px} qty={new_qty} (fresh lastUpdateAt)") + + ws_change = await wait_for_ws_order_change(maker, order_id, limit_px=new_px, qty=new_qty) + assert ws_change.order_id == order_id, f"WS orderChange must keep the orderId: {ws_change.order_id}" + logger.info(f"[{market_type}] ✅ WS orderChange carries the same orderId with the new px/qty") + + await maker.orders.cancel(order_id=order_id, symbol=market_config.symbol, account_id=maker.account_id) + await maker.check.no_open_orders() + + +@pytest.mark.asyncio +async def test_modify_by_client_order_id( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester +) -> None: + """Target the modify by client_order_id (order_id=None) — the resting + clientOrderId must also be restated into the signed envelope via + `resting_client_order_id` (the public Order model doesn't expose it). + + The clientOrderId → order → marketId resolution is the exact path that + broke twice for perp cancels (perpOB-6 Bug 11, PRO-143), so this is pinned + on both markets.""" + client_order_id = int(time.time() * 1_000_000) + order = await rest_gtc(maker, market_config, price_multiplier=0.50, client_order_id=client_order_id) + order_id = order.order_id + + new_px = str(market_config.price(0.52)) + new_qty = str(Decimal(market_config.min_qty) * 2) + response = await maker.client.modify_order( + full_state_modify_params( + order, + client_order_id=client_order_id, + resting_client_order_id=client_order_id, + limit_px=new_px, + qty=new_qty, + ) + ) + + assert response.order_id == order_id, f"orderId must be preserved: {response.order_id} != {order_id}" + assert response.status == OrderStatus.OPEN + + modified = await wait_for_order_fields(maker, order_id, limit_px=new_px, qty=new_qty) + assert modified.last_update_at > order.last_update_at, ( + f"lastUpdateAt must be refreshed by the modify: " + f"{modified.last_update_at} <= pre-modify {order.last_update_at}" + ) + logger.info(f"[{market_type}] ✅ modify by clientOrderId={client_order_id} OK: {order_id} (fresh lastUpdateAt)") + + ws_change = await wait_for_ws_order_change(maker, order_id, limit_px=new_px, qty=new_qty) + assert ws_change.order_id == order_id, f"WS orderChange must keep the orderId: {ws_change.order_id}" + logger.info(f"[{market_type}] ✅ WS orderChange carries the same orderId with the new px/qty") + + await maker.orders.cancel(order_id=order_id, symbol=market_config.symbol, account_id=maker.account_id) + await maker.check.no_open_orders() + + +@pytest.mark.asyncio +async def test_modify_flags_only( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester +) -> None: + """postOnly False→True→False, each step a single-field modify with the + rest of the state restated unchanged; then a non-zero expiresAfter on the + GTC is rejected client-side (only GTT carries a lifetime) leaving the order + untouched.""" + order = await rest_gtc(maker, market_config, price_multiplier=0.50) + order_id = order.order_id + + # postOnly False -> True (a resting order can't cross, so always legal). + response = await maker.client.modify_order(full_state_modify_params(order, post_only=True)) + assert response.order_id == order_id + order = await wait_for_order_fields(maker, order_id, post_only=True) + logger.info(f"[{market_type}] ✅ postOnly False -> True read back") + + # postOnly True -> False. + response = await maker.client.modify_order(full_state_modify_params(order, post_only=False)) + assert response.order_id == order_id + order = await wait_for_order_fields(maker, order_id, post_only=False) + logger.info(f"[{market_type}] ✅ postOnly True -> False read back") + + # expiresAfter is TIF-bound: the resting order is GTC, so any non-zero + # expiresAfter is rejected by the client's fail-fast coupling guard in + # build_modify_order_payload with a ValueError BEFORE the request is signed + # or sent — GTC never expires; only GTT carries a lifetime. GTT creation IS + # supported now; the positive GTT modify path (rest a GTT, then refresh its + # expiry) is deferred until GTT is on devnet1. The off-chain server enforces + # the same rule as defense-in-depth (covered by the off-chain handler tests). + future_expiry = int(time.time()) + 3600 + with pytest.raises(ValueError, match="GTC orders must not expire"): + await maker.client.modify_order(full_state_modify_params(order, expires_after=future_expiry)) + logger.info(f"[{market_type}] ✅ non-zero expiresAfter on a GTC rejected client-side before send") + + untouched = await maker.data.open_order(order_id) + assert untouched is not None, "Rejected expiresAfter modify must leave the order resting" + assert int(untouched.expires_after or 0) == 0, f"expiresAfter must stay 0: {untouched.expires_after}" + assert not untouched.post_only, f"Rejected modify must not flip postOnly: {untouched.post_only}" + logger.info(f"[{market_type}] ✅ order untouched after the rejected expiresAfter modify") + + await maker.orders.cancel(order_id=order_id, symbol=market_config.symbol, account_id=maker.account_id) + await maker.check.no_open_orders() diff --git a/tests/engine/test_modify_priority.py b/tests/engine/test_modify_priority.py new file mode 100644 index 00000000..2fae41c3 --- /dev/null +++ b/tests/engine/test_modify_priority.py @@ -0,0 +1,157 @@ +""" +modifyOrder queue-priority tests parametrized over [spot, perp] — live e2e, +two accounts. + +Queue rules under test (FIFO book): +- qty DOWN at an unchanged limitPx keeps the order's place in the queue, +- qty UP re-queues the order at the back of its level, +- any limitPx change re-queues (even when moved back to the original level). + +Setup pattern: maker1 (`maker`) rests FIRST at price P, maker2 (`taker`) rests +SECOND at the same P. After modifying maker1, a taker IOC crosses with exactly +one maker's size and the FIRST fill's `makerOrderId` reveals who held +priority. The IOC account is chosen per test so the sized-to-one-maker cross +can never reach its own resting order (no self-match): +- qty-down: IOC from `taker` (fills maker1, never reaches taker's maker2), +- qty-up / px-change: IOC from `maker` (fills maker2 — now first — and is + fully consumed before reaching maker's own re-queued maker1). + +All three tests need an empty book (external liquidity would absorb the +crosses) and produce a fill, so they wire the per-market settlement cleanup +(spot balance guard / perp baseline restore) via the autouse fixture. +Previously spot-only; parametrizing closes the perp queue-priority gap. +""" + +from decimal import Decimal + +import pytest + +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.builders.order_builder import full_state_modify_params +from tests.helpers.liquidity_detector import skip_if_external_config_liquidity +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig +from tests.helpers.order_lifecycle import rest_gtc, wait_for_order_fields, wait_for_taker_execution +from tests.helpers.reya_tester import logger + +pytestmark = [pytest.mark.e2e, pytest.mark.modify, pytest.mark.maker_taker] + +PRIORITY_REASON = "Queue-priority assertions need a controlled (empty) book." + + +@pytest.fixture(autouse=True) +def _settlement_cleanup(settlement_cleanup_guard): # pylint: disable=unused-argument + """All three tests produce a fill, so wire the per-market settlement + cleanup (spot balance guard / perp baseline restore).""" + yield + + +async def _taker_sell(tester: ReyaTester, market_config: SpotTestConfig | PerpTestConfig, price: str) -> str: + """IOC sell sized to exactly one maker (min_qty) at the queue price.""" + params = OrderBuilder().symbol(market_config.symbol).sell().price(price).qty(market_config.min_qty).ioc().build() + order_id = await tester.orders.create_limit(params) + assert order_id is not None + return order_id + + +@pytest.mark.asyncio +async def test_qty_down_keeps_priority( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester, taker: ReyaTester +) -> None: + """qty DOWN @ same px keeps queue priority: the taker's first (only) fill + is against the modified maker1, not maker2.""" + await skip_if_external_config_liquidity(market_config, maker, PRIORITY_REASON) + queue_px = str(market_config.price(0.99)) + double_qty = str(Decimal(market_config.min_qty) * 2) + + maker1 = await rest_gtc(maker, market_config, price_multiplier=0.99, qty=double_qty, is_buy=True) + maker2 = await rest_gtc(taker, market_config, price_multiplier=0.99, is_buy=True) + + response = await maker.client.modify_order(full_state_modify_params(maker1, qty=market_config.min_qty)) + assert response.order_id == maker1.order_id + assert response.status == OrderStatus.OPEN + logger.info(f"[{market_type}] maker1 {maker1.order_id} qty {maker1.qty} -> {market_config.min_qty} (same px)") + + taker_order_id = await _taker_sell(taker, market_config, price=queue_px) + execution = await wait_for_taker_execution(taker, market_type, taker_order_id) + assert str(execution.maker_order_id) == str(maker1.order_id), ( + f"[{market_type}] qty-down lost queue priority: taker filled maker {execution.maker_order_id}, " + f"expected maker1 {maker1.order_id}" + ) + logger.info(f"[{market_type}] ✅ qty-down kept priority: taker filled maker1 first") + + await maker.wait.for_order_state(maker1.order_id, OrderStatus.FILLED, timeout=5) + await taker.orders.cancel(order_id=maker2.order_id, symbol=market_config.symbol, account_id=taker.account_id) + await maker.check.no_open_orders() + await taker.check.no_open_orders() + + +@pytest.mark.asyncio +async def test_qty_up_loses_priority( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester, taker: ReyaTester +) -> None: + """qty UP re-queues: maker2 becomes first, so the taker's first fill is + against maker2.""" + await skip_if_external_config_liquidity(market_config, maker, PRIORITY_REASON) + queue_px = str(market_config.price(0.99)) + double_qty = str(Decimal(market_config.min_qty) * 2) + + maker1 = await rest_gtc(maker, market_config, price_multiplier=0.99, is_buy=True) + maker2 = await rest_gtc(taker, market_config, price_multiplier=0.99, is_buy=True) + + response = await maker.client.modify_order(full_state_modify_params(maker1, qty=double_qty)) + assert response.order_id == maker1.order_id + assert response.status == OrderStatus.OPEN + logger.info(f"[{market_type}] maker1 {maker1.order_id} qty {maker1.qty} -> {double_qty} (same px)") + + # IOC from `maker` (account A): sized to maker2's qty, fully consumed by + # maker2 (now first), so it can never self-match A's re-queued maker1. + taker_order_id = await _taker_sell(maker, market_config, price=queue_px) + execution = await wait_for_taker_execution(maker, market_type, taker_order_id) + assert str(execution.maker_order_id) == str(maker2.order_id), ( + f"[{market_type}] qty-up kept queue priority: taker filled maker {execution.maker_order_id}, " + f"expected maker2 {maker2.order_id}" + ) + logger.info(f"[{market_type}] ✅ qty-up lost priority: taker filled maker2 first") + + await taker.wait.for_order_state(maker2.order_id, OrderStatus.FILLED, timeout=5) + await maker.orders.cancel(order_id=maker1.order_id, symbol=market_config.symbol, account_id=maker.account_id) + await maker.check.no_open_orders() + await taker.check.no_open_orders() + + +@pytest.mark.asyncio +async def test_px_change_loses_priority( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester, taker: ReyaTester +) -> None: + """Any px change re-queues — even moving away and straight back to the + ORIGINAL level puts maker1 behind maker2.""" + await skip_if_external_config_liquidity(market_config, maker, PRIORITY_REASON) + queue_px = str(market_config.price(0.99)) + away_px = str(market_config.price(0.97)) + + maker1 = await rest_gtc(maker, market_config, price_multiplier=0.99, is_buy=True) + maker2 = await rest_gtc(taker, market_config, price_multiplier=0.99, is_buy=True) + + response = await maker.client.modify_order(full_state_modify_params(maker1, limit_px=away_px)) + assert response.order_id == maker1.order_id + # Poll for the moved state — the openOrders cache lags the modify response. + moved = await wait_for_order_fields(maker, maker1.order_id, limit_px=away_px) + response = await maker.client.modify_order(full_state_modify_params(moved, limit_px=queue_px)) + assert response.order_id == maker1.order_id + logger.info(f"[{market_type}] maker1 {maker1.order_id} px {queue_px} -> {away_px} -> {queue_px} (round trip)") + + # IOC from `maker` (account A) — see module docstring for the self-match reasoning. + taker_order_id = await _taker_sell(maker, market_config, price=queue_px) + execution = await wait_for_taker_execution(maker, market_type, taker_order_id) + assert str(execution.maker_order_id) == str(maker2.order_id), ( + f"[{market_type}] px round-trip kept queue priority: taker filled maker {execution.maker_order_id}, " + f"expected maker2 {maker2.order_id}" + ) + logger.info(f"[{market_type}] ✅ px change lost priority: taker filled maker2 first") + + await taker.wait.for_order_state(maker2.order_id, OrderStatus.FILLED, timeout=5) + await maker.orders.cancel(order_id=maker1.order_id, symbol=market_config.symbol, account_id=maker.account_id) + await maker.check.no_open_orders() + await taker.check.no_open_orders() diff --git a/tests/engine/test_modify_validation.py b/tests/engine/test_modify_validation.py new file mode 100644 index 00000000..c8452e08 --- /dev/null +++ b/tests/engine/test_modify_validation.py @@ -0,0 +1,429 @@ +""" +modifyOrder server-side validation tests — live e2e. + +- EMPTY_MODIFY_ERROR: an exact restate (no field changed) is rejected, +- ORDER_NOT_FOUND: bogus targets, just-cancelled orders, and fully-filled + orders, +- INPUT_VALIDATION_ERROR: zero px / zero qty / neither id / clientOrderId=0 — + driven as RAW `ModifyOrderRequest`s through the generated OrderEntryApi + because the typed `ModifyOrderParameters` path pre-empts these client-side + (pinned offline in tests/validation/test_client_guards.py). Mirrors the + raw-request precedent in tests/spot/test_api_validation.py. (Supplying BOTH + ids is valid under full-restate, so it is not a rejection case.) +- MODIFY_IMMUTABLE_MISMATCH (surfaced as INPUT_VALIDATION_ERROR): a restated + immutable that doesn't match the resting order — driven raw with a valid + signature over a flipped side. +- INPUT_VALIDATION_ERROR via raw JSON POSTs: each of the four required + modifiable fields omitted, plus negative limitPx/qty — the generated + pydantic request model can't express an omitted required field or a + negative qty, so these go over aiohttp directly (same precedent file). +- UNAUTHORIZED_SIGNATURE_ERROR: signature over one post-modify state, wire + payload carrying another, +- TP/SL trigger orders are not modifiable: the typed SDK restates + orderType=LIMIT, mismatching the resting trigger order, so the engine + rejects with MODIFY_IMMUTABLE_MISMATCH (INPUT_VALIDATION_ERROR). +""" + +from typing import Any + +import time +from decimal import Decimal + +import aiohttp +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.modify_order_request import ModifyOrderRequest +from sdk.open_api.models.order_status import OrderStatus +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api.auth.signatures import OrderTypeInt, TimeInForceInt +from sdk.reya_rest_api.models.orders import ModifyOrderParameters +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder, TriggerOrderBuilder +from tests.helpers.builders.order_builder import full_state_modify_params +from tests.helpers.liquidity_detector import skip_if_external_config_liquidity +from tests.helpers.market_config import SpotTestConfig +from tests.helpers.order_lifecycle import assert_px_qty, rest_spot_gtc +from tests.helpers.reya_tester import logger + +pytestmark = [pytest.mark.e2e, pytest.mark.modify, pytest.mark.validation] + +PERP_SYMBOL = "ETHRUSDPERP" +BOGUS_ORDER_ID = 999_999_999_999_999_999 + + +def _raw_modify_request( + tester: ReyaTester, + spot_config: SpotTestConfig, + limit_px: str, + qty: str, + order_id: int | None = None, + client_order_id: int | None = None, + is_buy: bool = True, +) -> ModifyOrderRequest: + """Build a raw ModifyOrderRequest with a REAL signature over exactly the + values sent (post-modify state of a resting buy GTC), so the server-side + INPUT validation — not signature recovery — is what rejects. `is_buy` + defaults to the resting order's side; flip it to forge an immutable + mismatch (the signature stays valid, but the restated side no longer + matches the resting order).""" + nonce = tester.get_next_nonce() + deadline = int(time.time()) + 60 + signature = tester.client.signature_generator.sign_order( + account_id=tester.account_id, + market_id=spot_config.market_id, + exchange_id=tester.client.config.dex_id, + order_type=int(OrderTypeInt.LIMIT), + is_buy=is_buy, + qty=Decimal(qty), + limit_price=Decimal(limit_px), + trigger_price=Decimal(0), + time_in_force=int(TimeInForceInt.GTC), + client_order_id=0, + reduce_only=False, + expires_after=0, + nonce=nonce, + deadline=deadline, + post_only=False, + ) + return ModifyOrderRequest( + orderId=str(order_id) if order_id is not None else None, + clientOrderId=client_order_id, + symbol=spot_config.symbol, + accountId=tester.account_id, + # Restated immutables (full-restate) — exactly the values signed above, + # so the signature stays valid and input validation is what rejects. + exchangeId=tester.client.config.dex_id, + isBuy=is_buy, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + triggerPx=None, + reduceOnly=False, + limitPx=limit_px, + qty=qty, + postOnly=False, + expiresAfter=0, + signature=signature, + nonce=str(nonce), + signerWallet=tester.client.signer_wallet_address, + deadline=deadline, + ) + + +def _strip_nones(payload: dict) -> dict: + """Drop None entries — mirrors what the SDK transports put on the wire.""" + return {key: value for key, value in payload.items() if value is not None} + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_empty_modify_rejected(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """An exact restate of the current order state is EMPTY_MODIFY_ERROR.""" + order = await rest_spot_gtc(spot_tester, spot_config, price_multiplier=0.95) + + with pytest.raises(ApiException) as exc_info: + await spot_tester.client.modify_order(full_state_modify_params(order)) + error_msg = str(exc_info.value) + assert "EMPTY_MODIFY_ERROR" in error_msg, f"Expected EMPTY_MODIFY_ERROR, got: {error_msg[:200]}" + logger.info("✅ Exact restate rejected with EMPTY_MODIFY_ERROR") + + still_open = await spot_tester.data.open_order(order.order_id) + assert still_open is not None, "Rejected empty modify must leave the order resting" + + await spot_tester.orders.cancel( + order_id=order.order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await spot_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_order_not_found(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """Both a bogus orderId and a just-cancelled order resolve to ORDER_NOT_FOUND.""" + bogus_params = ModifyOrderParameters( + symbol=spot_config.symbol, + is_buy=True, + limit_px=str(spot_config.price(0.95)), + qty=spot_config.min_qty, + post_only=False, + expires_after=0, + time_in_force=TimeInForce.GTC, + order_id=BOGUS_ORDER_ID, + ) + with pytest.raises(ApiException) as exc_info: + await spot_tester.client.modify_order(bogus_params) + error_msg = str(exc_info.value) + assert "ORDER_NOT_FOUND" in error_msg, f"Expected ORDER_NOT_FOUND for bogus id, got: {error_msg[:200]}" + logger.info("✅ Bogus orderId rejected with ORDER_NOT_FOUND") + + # A real order that was JUST cancelled is equally not-found. + order = await rest_spot_gtc(spot_tester, spot_config, price_multiplier=0.95) + await spot_tester.orders.cancel( + order_id=order.order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await spot_tester.wait.for_order_state(order.order_id, OrderStatus.CANCELLED, timeout=10) + + with pytest.raises(ApiException) as exc_info: + await spot_tester.client.modify_order(full_state_modify_params(order, limit_px=str(spot_config.price(0.96)))) + error_msg = str(exc_info.value) + assert "ORDER_NOT_FOUND" in error_msg, f"Expected ORDER_NOT_FOUND for cancelled order, got: {error_msg[:200]}" + logger.info("✅ Just-cancelled order rejected with ORDER_NOT_FOUND") + + await spot_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_modify_after_full_fill_not_found( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """A FULLY-FILLED order is no longer a live resting order — modifying its + stale snapshot resolves to ORDER_NOT_FOUND.""" + await skip_if_external_config_liquidity(spot_config, maker_tester, "A deterministic full fill needs an empty book.") + queue_px = str(spot_config.price(0.99)) + + maker_order = await rest_spot_gtc(maker_tester, spot_config, price_multiplier=0.99, is_buy=True) + + try: + ioc = OrderBuilder.from_config(spot_config).sell().price(queue_px).qty(spot_config.min_qty).ioc().build() + taker_order_id = await taker_tester.orders.create_limit(ioc) + assert taker_order_id is not None + await maker_tester.wait.for_order_state(maker_order.order_id, OrderStatus.FILLED, timeout=10) + logger.info(f"Maker order {maker_order.order_id} fully filled") + + with pytest.raises(ApiException) as exc_info: + await maker_tester.client.modify_order( + full_state_modify_params(maker_order, limit_px=str(spot_config.price(0.98))) + ) + error_msg = str(exc_info.value) + assert "ORDER_NOT_FOUND" in error_msg, f"Expected ORDER_NOT_FOUND for filled order, got: {error_msg[:200]}" + logger.info("✅ Fully-filled order rejected with ORDER_NOT_FOUND") + finally: + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_invalid_values_raw(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """Raw-request INPUT_VALIDATION_ERROR sweep against ONE resting order: + zero px, zero qty, neither id, clientOrderId=0. The resting order must come + through every rejection untouched. (Supplying BOTH orderId and clientOrderId + is valid under full-restate — orderId targets, clientOrderId is the restated + immutable — so it is not part of this rejection sweep.)""" + order = await rest_spot_gtc(spot_tester, spot_config, price_multiplier=0.95) + order_id = int(order.order_id) + original_px, original_qty = order.limit_px, order.qty + assert original_px is not None and original_qty is not None + good_px = str(spot_config.price(0.96)) + good_qty = spot_config.min_qty + + cases: list[tuple[str, dict[str, Any]]] = [ + ("zero px", {"limit_px": "0", "qty": good_qty, "order_id": order_id}), + ("zero qty", {"limit_px": good_px, "qty": "0", "order_id": order_id}), + ("neither id", {"limit_px": good_px, "qty": good_qty}), + ("clientOrderId=0", {"limit_px": good_px, "qty": good_qty, "client_order_id": 0}), + ] + try: + for label, kwargs in cases: + request = _raw_modify_request(spot_tester, spot_config, **kwargs) + with pytest.raises(ApiException) as exc_info: + await spot_tester.client.orders.modify_order(request) + error_msg = str(exc_info.value) + assert ( + "INPUT_VALIDATION_ERROR" in error_msg + ), f"[{label}] expected INPUT_VALIDATION_ERROR, got: {error_msg[:200]}" + logger.info(f"✅ [{label}] rejected with INPUT_VALIDATION_ERROR") + + untouched = await spot_tester.data.open_order(order.order_id) + assert untouched is not None, "Rejected modifies must leave the order resting" + assert_px_qty(untouched, expected_px=original_px, expected_qty=original_qty) + finally: + await spot_tester.orders.close_all(fail_if_none=False) + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_immutable_mismatch_raw(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """A restated immutable that doesn't match the resting order is rejected by + the matching engine's immutable-match (MODIFY_IMMUTABLE_MISMATCH, surfaced + as INPUT_VALIDATION_ERROR), and the resting order is left untouched. The + signature is valid over the tampered side, so it is the immutable-match — + not signature recovery — that rejects (full-restate end-to-end).""" + order = await rest_spot_gtc(spot_tester, spot_config, price_multiplier=0.95) # resting BUY + order_id = int(order.order_id) + original_px, original_qty = order.limit_px, order.qty + assert original_px is not None and original_qty is not None + try: + # Restate the side flipped (sell) against a resting buy — every other + # field is correct and the signature is valid over the flipped side, so + # only the engine's immutable-match can reject it. + request = _raw_modify_request( + spot_tester, + spot_config, + limit_px=str(spot_config.price(0.96)), + qty=spot_config.min_qty, + order_id=order_id, + is_buy=False, + ) + with pytest.raises(ApiException) as exc_info: + await spot_tester.client.orders.modify_order(request) + error_msg = str(exc_info.value) + assert ( + "INPUT_VALIDATION_ERROR" in error_msg + ), f"Expected INPUT_VALIDATION_ERROR (immutable mismatch), got: {error_msg[:200]}" + logger.info("✅ Restated immutable mismatch (side) rejected with INPUT_VALIDATION_ERROR") + + untouched = await spot_tester.data.open_order(order.order_id) + assert untouched is not None, "Rejected modify must leave the order resting" + assert_px_qty(untouched, expected_px=original_px, expected_qty=original_qty) + finally: + await spot_tester.orders.close_all(fail_if_none=False) + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_required_fields_and_negative_values_raw(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """The four modifiable fields are REQUIRED on the wire (no + omitted-means-inherited shorthand), and negative limitPx/qty are rejected + — INPUT_VALIDATION_ERROR for every case, resting order untouched. + + Driven as raw JSON POSTs over aiohttp (precedent: + tests/spot/test_api_validation.py) because the generated pydantic + `ModifyOrderRequest` cannot express an omitted required field or a + negative qty. Each payload starts from a fully-signed valid modify (fresh + nonce per case) and mutates exactly one wire field; the API-layer + validator rejects before signature recovery, so the signed-vs-wire + mismatch never surfaces.""" + order = await rest_spot_gtc(spot_tester, spot_config, price_multiplier=0.95) + original_px, original_qty = order.limit_px, order.qty + assert original_px is not None and original_qty is not None + good_px = str(spot_config.price(0.96)) + + url = f"{spot_tester.client.config.api_url}/modifyOrder" + omit_cases = ["limitPx", "qty", "postOnly", "expiresAfter"] + negative_cases = [("limitPx", f"-{good_px}"), ("qty", f"-{spot_config.min_qty}")] + + try: + async with aiohttp.ClientSession() as session: + for field_name in omit_cases: + payload, _nonce = spot_tester.client.build_modify_order_payload( + full_state_modify_params(order, limit_px=good_px) + ) + request_body = _strip_nones(payload) + del request_body[field_name] + async with session.post(url, json=request_body) as resp: + body = await resp.text() + assert resp.status == 400, f"[omit {field_name}] expected HTTP 400, got {resp.status}: {body[:200]}" + assert ( + "INPUT_VALIDATION_ERROR" in body + ), f"[omit {field_name}] expected INPUT_VALIDATION_ERROR, got: {body[:200]}" + logger.info(f"✅ [omit {field_name}] rejected with INPUT_VALIDATION_ERROR") + + for field_name, bad_value in negative_cases: + payload, _nonce = spot_tester.client.build_modify_order_payload( + full_state_modify_params(order, limit_px=good_px) + ) + request_body = _strip_nones(payload) + request_body[field_name] = bad_value + async with session.post(url, json=request_body) as resp: + body = await resp.text() + assert ( + resp.status == 400 + ), f"[negative {field_name}] expected HTTP 400, got {resp.status}: {body[:200]}" + assert ( + "INPUT_VALIDATION_ERROR" in body + ), f"[negative {field_name}] expected INPUT_VALIDATION_ERROR, got: {body[:200]}" + logger.info(f"✅ [negative {field_name}] rejected with INPUT_VALIDATION_ERROR") + + untouched = await spot_tester.data.open_order(order.order_id) + assert untouched is not None, "Rejected modifies must leave the order resting" + assert_px_qty(untouched, expected_px=original_px, expected_qty=original_qty) + finally: + await spot_tester.orders.close_all(fail_if_none=False) + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_tampered_signature(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """Sign one post-modify state, send a different qty on the wire → + UNAUTHORIZED_SIGNATURE_ERROR, order untouched.""" + order = await rest_spot_gtc(spot_tester, spot_config, price_multiplier=0.95) + original_px, original_qty = order.limit_px, order.qty + assert original_px is not None and original_qty is not None + + signed_qty = str(Decimal(spot_config.min_qty) * 2) + wire_qty = str(Decimal(spot_config.min_qty) * 3) + payload, _nonce = spot_tester.client.build_modify_order_payload(full_state_modify_params(order, qty=signed_qty)) + payload["qty"] = wire_qty # signature still covers signed_qty + + try: + with pytest.raises(ApiException) as exc_info: + await spot_tester.client.orders.modify_order(ModifyOrderRequest(**payload)) + error_msg = str(exc_info.value) + assert ( + "UNAUTHORIZED_SIGNATURE_ERROR" in error_msg + ), f"Expected UNAUTHORIZED_SIGNATURE_ERROR, got: {error_msg[:200]}" + logger.info("✅ Tampered modify rejected with UNAUTHORIZED_SIGNATURE_ERROR") + + untouched = await spot_tester.data.open_order(order.order_id) + assert untouched is not None + assert_px_qty(untouched, expected_px=original_px, expected_qty=original_qty) + finally: + await spot_tester.orders.close_all(fail_if_none=False) + + +@pytest.mark.perp +@pytest.mark.trigger +@pytest.mark.asyncio +async def test_trigger_order_not_modifiable(perp_maker_tester: ReyaTester): + """TP/SL trigger orders cannot be modified. Under full-restate the typed SDK + restates `orderType=LIMIT`, which mismatches the resting trigger order's + type, so the matching engine rejects it via the immutable-match + (`MODIFY_IMMUTABLE_MISMATCH`, surfaced as `INPUT_VALIDATION_ERROR`).""" + market_def = await perp_maker_tester.get_market_definition(PERP_SYMBOL) + min_qty = str(market_def.min_order_qty) + oracle_price = float(await perp_maker_tester.data.current_price(PERP_SYMBOL)) + far_trigger_px = str(round(oracle_price * 10, 2)) + + trigger_params = ( + TriggerOrderBuilder() + .symbol(PERP_SYMBOL) + .sell() + .qty(min_qty) + .trigger_price(far_trigger_px) + .take_profit() + .build() + ) + response = await perp_maker_tester.orders.create_trigger(trigger_params) + assert response.order_id is not None + trigger_order_id = response.order_id + + try: + modify_params = ModifyOrderParameters( + symbol=PERP_SYMBOL, + is_buy=False, + limit_px=str(round(oracle_price * 9, 2)), + qty=min_qty, + post_only=False, + expires_after=0, + time_in_force=TimeInForce.GTC, + order_id=int(trigger_order_id), + trigger_px=far_trigger_px, + ) + with pytest.raises(ApiException) as exc_info: + await perp_maker_tester.client.modify_order(modify_params) + error_msg = str(exc_info.value) + assert ( + "INPUT_VALIDATION_ERROR" in error_msg + ), f"Expected INPUT_VALIDATION_ERROR (immutable mismatch on orderType), got: {error_msg[:200]}" + logger.info("✅ Trigger order modify rejected with INPUT_VALIDATION_ERROR (orderType immutable mismatch)") + finally: + try: + await perp_maker_tester.client.cancel_order( + order_id=trigger_order_id, symbol=PERP_SYMBOL, account_id=perp_maker_tester.account_id + ) + except ApiException as e: + logger.warning(f"Trigger order cleanup cancel failed (may already be gone): {e}") diff --git a/tests/engine/test_modify_ws_exec.py b/tests/engine/test_modify_ws_exec.py new file mode 100644 index 00000000..5a1c7ea5 --- /dev/null +++ b/tests/engine/test_modify_ws_exec.py @@ -0,0 +1,326 @@ +""" +modifyOrder over ws-exec — live e2e. + +Same modify semantics as the REST surface, driven through +:class:`ReyaWsExecClient`. Gated on the same env as +tests/ws_exec/test_ws_exec.py: without `REYA_WS_EXEC_URL` + SPOT_*_1 +credentials the module collects-and-skips. + +Error envelopes surface as :class:`WsExecOperationError` with the structured +`RequestErrorCode` on `.code` — asserted directly. The message is additionally +pinned only where the code alone doesn't discriminate the rule +(INPUT_VALIDATION_ERROR), mirroring the REST suite. +""" + +from __future__ import annotations + +import asyncio +import os +import time +from decimal import Decimal + +import pytest +import pytest_asyncio +from dotenv import load_dotenv + +from sdk.open_api.models.order import Order +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api import ReyaTradingClient +from sdk.reya_rest_api.config import TradingConfig +from sdk.reya_rest_api.models.orders import LimitOrderParameters, ModifyOrderParameters +from sdk.reya_ws_exec import ReyaWsExecClient, WsExecOperationError +from tests.helpers.builders.order_builder import full_state_modify_params + +load_dotenv() + +_REQUIRED_ENV = ( + "REYA_WS_EXEC_URL", + "SPOT_PRIVATE_KEY_1", + "SPOT_ACCOUNT_ID_1", + "SPOT_WALLET_ADDRESS_1", +) +_MISSING_ENV = [_k for _k in _REQUIRED_ENV if not os.environ.get(_k)] + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.modify, + pytest.mark.skipif( + bool(_MISSING_ENV), + reason="ws-exec modify tests need " + ", ".join(_REQUIRED_ENV) + "; missing: " + ", ".join(_MISSING_ENV), + ), +] + +SPOT_SYMBOL = "WETHRUSD" +# Far-out resting prices on an ETH-priced book (same convention as +# tests/ws_exec/test_ws_exec.py): the GTC rests until cancelled. +REST_PX = "1" +MODIFIED_PX = "1.5" +BOGUS_ORDER_ID = 999_999_999_999_999_999 + + +async def _wait_for_open_order(rest: ReyaTradingClient, order_id: str, timeout_s: float = 10.0) -> Order: + """Poll openOrders until `order_id` appears — the OrdersProvider cache + consumes the ME's Redis stream asynchronously w.r.t. the ws-exec ack.""" + deadline = time.time() + timeout_s + while time.time() < deadline: + open_orders = await rest.get_open_orders() + order = next((o for o in open_orders if o.order_id == order_id), None) + if order is not None: + return order + await asyncio.sleep(0.2) + raise AssertionError(f"Order {order_id} not visible via REST within {timeout_s}s") + + +async def _wait_for_order_px_qty( + rest: ReyaTradingClient, order_id: str, px: str, qty: str, timeout_s: float = 10.0 +) -> Order: + """Poll openOrders until `order_id` reflects the post-modify px/qty.""" + deadline = time.time() + timeout_s + last: Order | None = None + while time.time() < deadline: + open_orders = await rest.get_open_orders() + last = next((o for o in open_orders if o.order_id == order_id), None) + if ( + last is not None + and last.qty is not None + and Decimal(last.limit_px) == Decimal(px) + and Decimal(last.qty) == Decimal(qty) + ): + return last + await asyncio.sleep(0.2) + raise AssertionError( + f"Order {order_id} did not reflect px={px} qty={qty} within {timeout_s}s; " + f"last seen: px={last.limit_px if last else None} qty={last.qty if last else None}" + ) + + +async def _wait_for_order_post_only( + rest: ReyaTradingClient, order_id: str, expected: bool, timeout_s: float = 10.0 +) -> Order: + """Poll openOrders until `order_id` reflects the post-modify postOnly flag.""" + deadline = time.time() + timeout_s + last: Order | None = None + while time.time() < deadline: + open_orders = await rest.get_open_orders() + last = next((o for o in open_orders if o.order_id == order_id), None) + if last is not None and bool(last.post_only) == expected: + return last + await asyncio.sleep(0.2) + raise AssertionError( + f"Order {order_id} did not reflect postOnly={expected} within {timeout_s}s; " + f"last seen: postOnly={last.post_only if last else None}" + ) + + +@pytest_asyncio.fixture(loop_scope="session", scope="module") +async def modify_ws_harness(): + """A started spot REST client + connected ws-exec client + market min qty.""" + config = TradingConfig.from_env_spot(account_number=1) + rest = ReyaTradingClient(config) + await rest.start() + ws: ReyaWsExecClient | None = None + # Everything after start() runs inside the try so a skip (or a connect + # failure) never leaks the started REST client's aiohttp session. + try: + markets = {m.symbol: m for m in await rest.reference.get_spot_market_definitions()} + if SPOT_SYMBOL not in markets: + pytest.skip(f"{SPOT_SYMBOL} not found in /spotMarketDefinitions") + ws = ReyaWsExecClient(rest_client=rest, ws_url=os.environ["REYA_WS_EXEC_URL"]) + await ws.connect() + yield rest, ws, str(markets[SPOT_SYMBOL].min_order_qty) + finally: + if ws is not None: + await ws.close() + await rest.close() + + +async def test_ws_exec_modify(modify_ws_harness): # pylint: disable=redefined-outer-name + """Happy modify over ws-exec: rest a GTC, modify px+qty in place, + orderId preserved and openOrders (REST) reflects the new values.""" + rest, ws, min_qty = modify_ws_harness + + create = await ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=REST_PX, + qty=min_qty, + time_in_force=TimeInForce.GTC, + ) + ) + assert create.order_id is not None + order_id = create.order_id + + try: + order = await _wait_for_open_order(rest, order_id) + + new_qty = str(Decimal(min_qty) * 2) + response = await ws.modify_order(full_state_modify_params(order, limit_px=MODIFIED_PX, qty=new_qty)) + assert response.order_id == order_id, f"orderId must be preserved: {response.order_id} != {order_id}" + print(f" [ws-exec] modify OK orderId={response.order_id} status={response.status}") + + modified = await _wait_for_order_px_qty(rest, order_id, px=MODIFIED_PX, qty=new_qty) + print(f" [ws-exec] openOrders reflects px={modified.limit_px} qty={modified.qty}") + finally: + await ws.cancel_order(order_id=order_id, symbol=SPOT_SYMBOL, account_id=rest.config.account_id) + + +async def test_ws_exec_modify_not_found_error_envelope(modify_ws_harness): # pylint: disable=redefined-outer-name + """Modifying a non-existent order surfaces the per-op error envelope as + WsExecOperationError with code ORDER_NOT_FOUND.""" + _rest, ws, min_qty = modify_ws_harness + + params = ModifyOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=REST_PX, + qty=min_qty, + post_only=False, + expires_after=0, + time_in_force=TimeInForce.GTC, + order_id=BOGUS_ORDER_ID, + ) + with pytest.raises(WsExecOperationError) as exc_info: + await ws.modify_order(params) + assert exc_info.value.code == "ORDER_NOT_FOUND", f"Expected ORDER_NOT_FOUND, got {exc_info.value.code}" + print(f" [ws-exec] not-found modify rejected OK code={exc_info.value.code}") + + +async def test_ws_exec_modify_by_client_order_id(modify_ws_harness): # pylint: disable=redefined-outer-name + """Request-mapping fidelity: targeting the modify BY clientOrderId puts a + distinct wire shape through WsModifyOrderRequest — `orderId` is None in the + payload so `exclude_none` must DROP it from the frame, and the dispatcher + must resolve the target from `clientOrderId` alone. The response still + carries the preserved engine orderId, and the REST read-back proves the + modify reached the real order (the engine-side targeting semantics are + already proven via REST in test_modify_happy.py::test_modify_by_client_order_id).""" + rest, ws, min_qty = modify_ws_harness + + client_order_id = int(time.time() * 1_000_000) + create = await ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=REST_PX, + qty=min_qty, + time_in_force=TimeInForce.GTC, + client_order_id=client_order_id, + ) + ) + assert create.order_id is not None + order_id = create.order_id + + try: + order = await _wait_for_open_order(rest, order_id) + + new_qty = str(Decimal(min_qty) * 2) + # full_state_modify_params clears order_id when client_order_id is + # overridden, so the wire payload omits orderId entirely; the resting + # clientOrderId must also be restated into the signed envelope. + response = await ws.modify_order( + full_state_modify_params( + order, + client_order_id=client_order_id, + resting_client_order_id=client_order_id, + limit_px=MODIFIED_PX, + qty=new_qty, + ) + ) + assert response.order_id == order_id, f"orderId must be preserved: {response.order_id} != {order_id}" + print(f" [ws-exec] modify by clientOrderId={client_order_id} OK orderId={response.order_id}") + + modified = await _wait_for_order_px_qty(rest, order_id, px=MODIFIED_PX, qty=new_qty) + print(f" [ws-exec] openOrders reflects px={modified.limit_px} qty={modified.qty}") + finally: + await ws.cancel_order(order_id=order_id, symbol=SPOT_SYMBOL, account_id=rest.config.account_id) + + +async def test_ws_exec_modify_flags_and_expires_after_envelope( + modify_ws_harness, +): # pylint: disable=redefined-outer-name + """Two transport concerns in one deterministic flow: (1) flag fidelity — + postOnly False→True→False survives WsModifyOrderRequest's boolean + serialization, read back via REST openOrders (the WS modify response + carries no postOnly echo); (2) coupling guard — a non-zero expiresAfter on + the resting GTC is rejected by the shared client coupling guard in + build_modify_order_payload (reused by the ws-exec transport) with a + ValueError BEFORE anything is signed or sent — GTC never expires; only GTT + carries a lifetime — leaving the order untouched. The off-chain server + enforces the same rule as defense-in-depth.""" + rest, ws, min_qty = modify_ws_harness + + create = await ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=REST_PX, + qty=min_qty, + time_in_force=TimeInForce.GTC, + ) + ) + assert create.order_id is not None + order_id = create.order_id + + try: + order = await _wait_for_open_order(rest, order_id) + + # postOnly False -> True (a resting order can't cross, so always legal). + response = await ws.modify_order(full_state_modify_params(order, post_only=True)) + assert response.order_id == order_id, f"orderId must be preserved: {response.order_id} != {order_id}" + order = await _wait_for_order_post_only(rest, order_id, expected=True) + print(" [ws-exec] postOnly False -> True read back") + + # postOnly True -> False. + response = await ws.modify_order(full_state_modify_params(order, post_only=False)) + assert response.order_id == order_id, f"orderId must be preserved: {response.order_id} != {order_id}" + order = await _wait_for_order_post_only(rest, order_id, expected=False) + print(" [ws-exec] postOnly True -> False read back") + + # expiresAfter is TIF-bound: the resting order is GTC, so any non-zero + # expiresAfter is rejected by the shared client coupling guard in + # build_modify_order_payload with a ValueError before signing/sending. + future_expiry = int(time.time()) + 3600 + with pytest.raises(ValueError, match="GTC orders must not expire"): + await ws.modify_order(full_state_modify_params(order, expires_after=future_expiry)) + print(" [ws-exec] non-zero expiresAfter on GTC rejected client-side before send") + + untouched = await _wait_for_open_order(rest, order_id) + assert int(untouched.expires_after or 0) == 0, f"expiresAfter must stay 0: {untouched.expires_after}" + assert not untouched.post_only, f"Rejected modify must not flip postOnly: {untouched.post_only}" + print(" [ws-exec] order untouched after the rejected expiresAfter modify") + finally: + await ws.cancel_order(order_id=order_id, symbol=SPOT_SYMBOL, account_id=rest.config.account_id) + + +async def test_ws_exec_empty_modify_error_envelope(modify_ws_harness): # pylint: disable=redefined-outer-name + """Business-rejection envelope breadth: an exact restate (no field + changed) maps through the ws-exec per-op error envelope as + WsExecOperationError EMPTY_MODIFY_ERROR — a second, code-specific + modifyOrder rejection beyond ORDER_NOT_FOUND, deterministic and with no + counterparty needed. The resting order survives the rejection.""" + rest, ws, min_qty = modify_ws_harness + + create = await ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=REST_PX, + qty=min_qty, + time_in_force=TimeInForce.GTC, + ) + ) + assert create.order_id is not None + order_id = create.order_id + + try: + order = await _wait_for_open_order(rest, order_id) + + with pytest.raises(WsExecOperationError) as exc_info: + await ws.modify_order(full_state_modify_params(order)) + assert exc_info.value.code == "EMPTY_MODIFY_ERROR", f"Expected EMPTY_MODIFY_ERROR, got {exc_info.value.code}" + print(f" [ws-exec] exact restate rejected OK code={exc_info.value.code}") + + still_open = await _wait_for_open_order(rest, order_id) + assert still_open is not None, "Rejected empty modify must leave the order resting" + finally: + await ws.cancel_order(order_id=order_id, symbol=SPOT_SYMBOL, account_id=rest.config.account_id) diff --git a/tests/test_orderbook/test_order_cancellation.py b/tests/engine/test_order_cancellation.py similarity index 53% rename from tests/test_orderbook/test_order_cancellation.py rename to tests/engine/test_order_cancellation.py index c6d9c328..189196f8 100644 --- a/tests/test_orderbook/test_order_cancellation.py +++ b/tests/engine/test_order_cancellation.py @@ -13,6 +13,7 @@ from __future__ import annotations import asyncio +import time import pytest @@ -21,8 +22,7 @@ from sdk.open_api.models.time_in_force import TimeInForce from sdk.reya_rest_api.models import LimitOrderParameters from tests.helpers import ReyaTester -from tests.test_orderbook.conftest import PerpTestConfig -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig def _safe_resting_price(market_config: SpotTestConfig | PerpTestConfig) -> str: @@ -93,6 +93,48 @@ async def test_mass_cancel_clears_multiple_orders( await maker.check.no_open_orders() +@pytest.mark.asyncio +async def test_cancel_by_client_order_id( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """Cancel targeting by clientOrderId ONLY (orderId deliberately omitted). + + The server resolves clientOrderId only when orderId is absent, so sending + both (as the legacy spot test does) never exercises the resolution path. + That clientOrderId → order → marketId routing broke twice on perp + (perpOB-6 Bug 11, PRO-143), hence both markets pin it here. + """ + client_order_id = int(time.time() * 1000) % (2**31 - 1) + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=_safe_resting_price(market_config), + qty=market_config.min_qty, + time_in_force=TimeInForce.GTC, + client_order_id=client_order_id, + ) + response = await maker.client.create_limit_order(params) + order_id = response.order_id + assert order_id is not None, f"[{market_type}] expected order_id" + # The create response must echo the clientOrderId it was created with + # (folded from test_spot_gtc_with_client_order_id when that test was + # retired as a weaker duplicate; the public Order model doesn't expose it). + if response.client_order_id is not None: + assert int(response.client_order_id) == client_order_id, f"[{market_type}] clientOrderId echo mismatch" + await maker.wait.for_order_creation(order_id) + + await maker.client.cancel_order( + symbol=market_config.symbol, + account_id=maker.account_id, + client_order_id=client_order_id, + ) + + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED) + await maker.check.no_open_orders() + + @pytest.mark.asyncio async def test_cancel_unknown_order_id_rejects( market_config: SpotTestConfig | PerpTestConfig, @@ -153,3 +195,79 @@ async def test_cancel_already_cancelled_rejects( assert ( "missing" in err or "not found" in err or "cancel" in err or "400" in err or "404" in err ), f"[{market_type}] expected explicit cancel rejection, got: {exc_info.value}" + + +@pytest.mark.asyncio +async def test_cancel_already_filled_rejects( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """Cancelling a FILLED order raises — the order is gone from the book. + + Moved from tests/spot/test_order_cancellation.py (which accepted + both outcomes) and hardened: the rejection is now asserted, on both + markets, against a controlled book. + """ + await market_config.refresh_order_book(taker.data) + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + if market_config.has_any_external_liquidity: + pytest.skip("external liquidity present — the fill could route externally") + + cross_px = str(market_config.price(0.99)) + maker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=cross_px, + qty=market_config.min_qty, + time_in_force=TimeInForce.GTC, + ) + maker_order_id = await maker.orders.create_limit(maker_params) + assert maker_order_id is not None + await maker.wait.for_order_creation(maker_order_id) + + taker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=False, + limit_px=cross_px, + qty=market_config.min_qty, + time_in_force=TimeInForce.IOC, + ) + await taker.orders.create_limit(taker_params) + await maker.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=10) + + with pytest.raises(ApiException) as exc_info: + await maker.client.cancel_order( + symbol=market_config.symbol, + account_id=maker.account_id, + order_id=maker_order_id, + ) + err = str(exc_info.value).lower() + assert ( + "missing" in err or "not found" in err or "cancel" in err or "400" in err or "404" in err + ), f"[{market_type}] expected explicit rejection for a FILLED order, got: {exc_info.value}" + + +@pytest.mark.asyncio +async def test_mass_cancel_with_no_orders_is_noop( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """Mass-cancel with nothing resting succeeds as a no-op (no error). + + Merged from two near-identical spot tests (mass_cancel_empty_book / + mass_cancel_no_orders) and parametrized. + """ + await maker.orders.close_all(fail_if_none=False) + await maker.check.no_open_orders() + + response = await maker.client.mass_cancel( + symbol=market_config.symbol, + account_id=maker.account_id, + ) + assert response is not None, f"[{market_type}] mass-cancel on an empty book must not error" + + await maker.check.no_open_orders() diff --git a/tests/engine/test_post_only_rejection.py b/tests/engine/test_post_only_rejection.py new file mode 100644 index 00000000..3552ee17 --- /dev/null +++ b/tests/engine/test_post_only_rejection.py @@ -0,0 +1,218 @@ +""" +Post-only rejection tests parametrized over [spot, perp] — live e2e. + +A post-only order whose entry WOULD cross the best opposite price is rejected +with POST_ONLY_WOULD_CROSS_ERROR — the touch counts as a cross — with NO order +created (openOrders unchanged) and the book left untouched. postOnly is a +LIMIT-order flag: a TP/SL trigger carrying it is rejected at request +validation (perp-only, since triggers exist only on perp). + +Controlled-counterparty pattern: account B (`taker`) rests a known maker on +the opposite side FIRST, then account A (`maker`) probes with the post-only +order; after the rejection A's openOrders snapshot is unchanged and B's maker +still rests at its original px/qty. All engineered probes need an empty book +(external liquidity would shift the best opposite price). +""" + +import time +from decimal import Decimal + +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.create_order_request import CreateOrderRequest +from sdk.open_api.models.order_type import OrderType +from sdk.reya_rest_api.auth.signatures import OrderTypeInt, TimeInForceInt +from tests.engine.post_only_helpers import ( + PERP_SYMBOL, + open_order_ids, + perp_min_qty_and_oracle, + rest_gtc_at_price, +) +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.liquidity_detector import skip_if_external_config_liquidity +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig +from tests.helpers.order_lifecycle import assert_px_qty +from tests.helpers.reya_tester import logger + +pytestmark = [pytest.mark.e2e, pytest.mark.post_only] + +REJECTION_REASON = "Engineered would-cross probes need a controlled (empty) book." + + +async def _assert_probe_rejected_book_untouched( + prober: ReyaTester, + counterparty_tester: ReyaTester, + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + counterparty_order_id: str, + counterparty_px: str, + probe_px: str, +) -> None: + """Submit account A's post-only buy at `probe_px` against B's resting ask; + assert POST_ONLY_WOULD_CROSS_ERROR, A's openOrders unchanged, B's maker + untouched.""" + before_ids = await open_order_ids(prober) + + params = ( + OrderBuilder() + .symbol(market_config.symbol) + .buy() + .price(probe_px) + .qty(market_config.min_qty) + .post_only() + .gtc() + .build() + ) + with pytest.raises(ApiException) as exc_info: + await prober.client.create_limit_order(params) + error_msg = str(exc_info.value) + assert ( + "POST_ONLY_WOULD_CROSS_ERROR" in error_msg + ), f"[{market_type}] expected POST_ONLY_WOULD_CROSS_ERROR, got: {error_msg[:200]}" + logger.info(f"[{market_type}] ✅ post-only buy @ {probe_px} against ask @ {counterparty_px} rejected") + + assert await open_order_ids(prober) == before_ids, "Rejected post-only must not create an order" + untouched = await counterparty_tester.data.open_order(counterparty_order_id) + assert untouched is not None, "Book must be untouched: the resting ask was consumed or cancelled" + assert_px_qty(untouched, expected_px=counterparty_px, expected_qty=market_config.min_qty) + logger.info(f"[{market_type}] ✅ openOrders unchanged for the prober; counterparty maker untouched") + + +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_post_only_would_cross_rejected( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester, taker: ReyaTester +) -> None: + """A post-only buy strictly ABOVE the best ask is rejected with + POST_ONLY_WOULD_CROSS_ERROR; no order created, book untouched.""" + await skip_if_external_config_liquidity(market_config, maker, REJECTION_REASON) + + ask_px = str(market_config.price(1.01)) + counterparty = await rest_gtc_at_price( + taker, market_config.symbol, price=ask_px, qty=market_config.min_qty, is_buy=False + ) + + try: + await _assert_probe_rejected_book_untouched( + prober=maker, + counterparty_tester=taker, + market_config=market_config, + market_type=market_type, + counterparty_order_id=counterparty.order_id, + counterparty_px=ask_px, + probe_px=str(market_config.price(1.02)), + ) + finally: + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + + +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_post_only_touch_price_rejected( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester, taker: ReyaTester +) -> None: + """A post-only buy at EXACTLY the best ask is rejected — the touch counts + as a cross (the >=-vs-> boundary of the book's cross check).""" + await skip_if_external_config_liquidity(market_config, maker, REJECTION_REASON) + + ask_px = str(market_config.price(1.01)) + counterparty = await rest_gtc_at_price( + taker, market_config.symbol, price=ask_px, qty=market_config.min_qty, is_buy=False + ) + + try: + await _assert_probe_rejected_book_untouched( + prober=maker, + counterparty_tester=taker, + market_config=market_config, + market_type=market_type, + counterparty_order_id=counterparty.order_id, + counterparty_px=ask_px, + probe_px=ask_px, + ) + finally: + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + + +@pytest.mark.perp +@pytest.mark.trigger +@pytest.mark.asyncio +async def test_post_only_on_trigger_order_rejected(perp_maker_tester: ReyaTester): + """postOnly is a LIMIT-order flag — a TP trigger carrying postOnly=True is + rejected and no order is created. Perp-only: trigger orders exist only on + perp markets. + + Driven as a RAW CreateOrderRequest because TriggerOrderParameters has no + post_only field (the typed path cannot express the invalid combination). + The signature covers the EXACT wire values — post_only=True included, and + the trigger envelope mirrors build_create_trigger_order_payload (GTC + time-in-force, expiresAfter=0, no reduceOnly) — so the server's + trigger/post-only validation, not signature recovery, is what rejects. + """ + min_qty, oracle_price = await perp_min_qty_and_oracle(perp_maker_tester) + trigger_px = str(round(oracle_price * 10, 2)) + limit_px = str(round(oracle_price * 9, 2)) + + nonce = perp_maker_tester.get_next_nonce() + deadline = int(time.time()) + 60 + signature = perp_maker_tester.client.signature_generator.sign_order( + account_id=perp_maker_tester.account_id, + market_id=perp_maker_tester.client.get_market_id_from_symbol(PERP_SYMBOL), + exchange_id=perp_maker_tester.client.config.dex_id, + order_type=int(OrderTypeInt.TAKE_PROFIT), + is_buy=False, + qty=Decimal(min_qty), + limit_price=Decimal(limit_px), + trigger_price=Decimal(trigger_px), + time_in_force=int(TimeInForceInt.GTC), + client_order_id=0, + reduce_only=False, + expires_after=0, + nonce=nonce, + deadline=deadline, + post_only=True, + ) + request = CreateOrderRequest( + accountId=perp_maker_tester.account_id, + symbol=PERP_SYMBOL, + exchangeId=perp_maker_tester.client.config.dex_id, + isBuy=False, + limitPx=limit_px, + qty=min_qty, + triggerPx=trigger_px, + orderType=OrderType.TAKE_PROFIT, + reduceOnly=None, + postOnly=True, + expiresAfter=0, + signature=signature, + nonce=str(nonce), + signerWallet=perp_maker_tester.client.signer_wallet_address, + deadline=deadline, + ) + + try: + response = await perp_maker_tester.client.orders.create_order(create_order_request=request) + # Defensive cleanup if validation regressed and the trigger was accepted. + if response.order_id is not None: + await perp_maker_tester.client.cancel_order( + order_id=response.order_id, symbol=PERP_SYMBOL, account_id=perp_maker_tester.account_id + ) + pytest.fail(f"postOnly on a TP trigger should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e) + # Request-shape validation pins this exact case: the off-chain order + # validator pushes 'postOnly is not supported for TP/SL orders', + # surfaced as INPUT_VALIDATION_ERROR. Pinning the message keeps an + # unrelated rejection (rate limit, kill switch, balance) from + # false-passing without exercising the postOnly/trigger rule. + assert "INPUT_VALIDATION_ERROR" in error_msg, f"Expected INPUT_VALIDATION_ERROR, got: {error_msg[:200]}" + assert ( + "postOnly is not supported for TP/SL orders" in error_msg + ), f"Expected the TP/SL postOnly message, got: {error_msg[:200]}" + logger.info(f"✅ postOnly on a TP trigger rejected: {error_msg[:120]}") + + await perp_maker_tester.check.no_open_orders() diff --git a/tests/engine/test_post_only_resting.py b/tests/engine/test_post_only_resting.py new file mode 100644 index 00000000..14242949 --- /dev/null +++ b/tests/engine/test_post_only_resting.py @@ -0,0 +1,128 @@ +""" +Post-only resting-path tests parametrized over [spot, perp] — live e2e. + +`postOnly` constrains ENTRY only: a post-only GTC that does not cross rests +like any other GTC — the flag reads back True via REST openOrders AND the WS +order event — the tightest legal placement (one tick away from the best +opposite price) is accepted, and once RESTING the maker fills normally when a +taker crosses it. The rejection half (would-cross / touch / triggers) lives in +tests/engine/test_post_only_rejection.py. + +Only `test_resting_post_only_maker_fills_when_taken` produces a fill (min_qty +maker→taker every run), so the module wires the per-market settlement cleanup +(spot balance guard / perp baseline restore) via the autouse fixture. +""" + +from decimal import Decimal + +import pytest + +from sdk.open_api.models.order_status import OrderStatus +from tests.engine.post_only_helpers import rest_gtc_at_price, wait_for_ws_order_event +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.liquidity_detector import skip_if_external_config_liquidity +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig +from tests.helpers.order_lifecycle import assert_px_qty, wait_for_taker_execution +from tests.helpers.reya_tester import logger + +pytestmark = [pytest.mark.e2e, pytest.mark.post_only] + +RESTING_REASON = "Deterministic post-only placements need a controlled (empty) book." + + +@pytest.fixture(autouse=True) +def _settlement_cleanup(settlement_cleanup_guard): # pylint: disable=unused-argument + """`test_resting_post_only_maker_fills_when_taken` produces a fill, so wire + the per-market settlement cleanup (spot balance guard / perp baseline + restore).""" + yield + + +@pytest.mark.asyncio +async def test_post_only_rests_away_from_book( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester +) -> None: + """A post-only GTC far from the touch rests OPEN; postOnly reads back True + via REST openOrders AND the WS order event.""" + buy_px = str(market_config.price(0.50)) + order = await rest_gtc_at_price( + maker, market_config.symbol, price=buy_px, qty=market_config.min_qty, is_buy=True, post_only=True + ) + assert order.post_only is True, f"REST openOrders must read postOnly back as True: {order.post_only}" + + await wait_for_ws_order_event(maker, order.order_id) + ws_order = maker.check.ws_order_change_received( + order.order_id, expected_symbol=market_config.symbol, expected_status=OrderStatus.OPEN + ) + assert ws_order.post_only is True, f"WS order event must carry postOnly=True: {ws_order.post_only}" + logger.info(f"[{market_type}] ✅ post-only rested OPEN with postOnly=True via REST + WS: {order.order_id}") + + await maker.orders.cancel(order_id=order.order_id, symbol=market_config.symbol, account_id=maker.account_id) + await maker.check.no_open_orders() + + +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_post_only_one_tick_from_touch_rests( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester, taker: ReyaTester +) -> None: + """The tightest legal post-only placement — one tick away from the best + opposite price — rests (only the touch itself counts as a cross). Tick size + is per-market reference data, so this is pinned on both books.""" + await skip_if_external_config_liquidity(market_config, maker, RESTING_REASON) + + # Snap the ask to the tick grid so probe = ask - tick is also grid-valid. + tick = Decimal(market_config.tick_size) + oracle = Decimal(str(market_config.oracle_price)) + ask = (oracle * Decimal("1.01")) // tick * tick + ask_px = str(ask) + probe_px = str(ask - tick) + + counterparty = await rest_gtc_at_price( + taker, market_config.symbol, price=ask_px, qty=market_config.min_qty, is_buy=False + ) + + try: + order = await rest_gtc_at_price( + maker, market_config.symbol, price=probe_px, qty=market_config.min_qty, is_buy=True, post_only=True + ) + assert order.post_only is True, f"REST openOrders must read postOnly back as True: {order.post_only}" + assert_px_qty(order, expected_px=probe_px, expected_qty=market_config.min_qty) + logger.info(f"[{market_type}] ✅ post-only buy rested one tick ({tick}) below the best ask {ask_px}") + + untouched = await taker.data.open_order(counterparty.order_id) + assert untouched is not None, "The opposite-side maker must stay resting (nothing crossed)" + finally: + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + + +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_resting_post_only_maker_fills_when_taken( + market_config: SpotTestConfig | PerpTestConfig, market_type: str, maker: ReyaTester, taker: ReyaTester +) -> None: + """post_only constrains ENTRY only: a RESTING post-only maker fills + normally when a taker crosses it.""" + await skip_if_external_config_liquidity(market_config, maker, RESTING_REASON) + queue_px = str(market_config.price(0.99)) + + maker_order = await rest_gtc_at_price( + maker, market_config.symbol, price=queue_px, qty=market_config.min_qty, is_buy=True, post_only=True + ) + assert maker_order.post_only is True + + ioc = OrderBuilder().symbol(market_config.symbol).sell().price(queue_px).qty(market_config.min_qty).ioc().build() + taker_order_id = await taker.orders.create_limit(ioc) + assert taker_order_id is not None + + execution = await wait_for_taker_execution(taker, market_type, taker_order_id) + assert str(execution.maker_order_id) == str( + maker_order.order_id + ), f"Taker filled maker {execution.maker_order_id}, expected the post-only maker {maker_order.order_id}" + await maker.wait.for_order_state(maker_order.order_id, OrderStatus.FILLED, timeout=5) + logger.info(f"[{market_type}] ✅ resting post-only maker filled normally when taken") + + await maker.check.no_open_orders() + await taker.check.no_open_orders() diff --git a/tests/engine/test_post_only_validation.py b/tests/engine/test_post_only_validation.py new file mode 100644 index 00000000..364664e2 --- /dev/null +++ b/tests/engine/test_post_only_validation.py @@ -0,0 +1,102 @@ +""" +post-only + IOC validation — the case is split between the SDK and the API. + +The combination is self-contradictory (IOC is taker-only; post_only requires +the order to rest), so the SDK refuses to even build the payload (client +guard, pinned here against the live client). The raw half drives a generated +CreateOrderRequest with a REAL signature over the exact wire values +(post_only=True, IOC) — mirroring tests/spot/test_api_validation.py — +to prove the server independently rejects it with INPUT_VALIDATION_ERROR. +Both probes price at the safe no-match buy price so even a validation +regression could not move the book (an IOC buy at $10 cancels unfilled). +""" + +import time +from decimal import Decimal + +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.create_order_request import CreateOrderRequest +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api.auth.signatures import OrderTypeInt, TimeInForceInt +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.market_config import SpotTestConfig +from tests.helpers.reya_tester import logger + +pytestmark = [pytest.mark.e2e, pytest.mark.post_only, pytest.mark.validation] + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_post_only_ioc_rejected_by_client_guard(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """The SDK raises locally on post_only + IOC — nothing reaches the wire.""" + params = ( + OrderBuilder.from_config(spot_config) + .buy() + .price(str(spot_config.get_safe_no_match_buy_price())) + .ioc() + .post_only() + .build() + ) + + with pytest.raises(ValueError, match="post_only is not supported on IOC orders"): + await spot_tester.client.create_limit_order(params) + logger.info("✅ SDK client guard refused post_only + IOC locally") + + await spot_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_post_only_ioc_raw_rejected_by_api(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """Bypassing the client guard with a raw CreateOrderRequest (real + signature over the exact post_only=True + IOC wire values) is rejected by + the API with INPUT_VALIDATION_ERROR.""" + buy_px = str(spot_config.get_safe_no_match_buy_price()) + nonce = spot_tester.get_next_nonce() + deadline = int(time.time()) + 60 + signature = spot_tester.client.signature_generator.sign_order( + account_id=spot_tester.account_id, + market_id=spot_config.market_id, + exchange_id=spot_tester.client.config.dex_id, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal(spot_config.min_qty), + limit_price=Decimal(buy_px), + trigger_price=Decimal(0), + time_in_force=int(TimeInForceInt.IOC), + client_order_id=0, + reduce_only=False, + expires_after=0, + nonce=nonce, + deadline=deadline, + post_only=True, + ) + request = CreateOrderRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + exchangeId=spot_tester.client.config.dex_id, + isBuy=True, + limitPx=buy_px, + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.IOC, + reduceOnly=None, + postOnly=True, + expiresAfter=0, + signature=signature, + nonce=str(nonce), + signerWallet=spot_tester.client.signer_wallet_address, + deadline=deadline, + ) + + with pytest.raises(ApiException) as exc_info: + await spot_tester.client.orders.create_order(create_order_request=request) + error_msg = str(exc_info.value) + assert "INPUT_VALIDATION_ERROR" in error_msg, f"Expected INPUT_VALIDATION_ERROR, got: {error_msg[:200]}" + logger.info("✅ Raw post_only + IOC rejected by the API with INPUT_VALIDATION_ERROR") + + await spot_tester.check.no_open_orders() diff --git a/tests/engine/test_post_only_ws_exec.py b/tests/engine/test_post_only_ws_exec.py new file mode 100644 index 00000000..9872349e --- /dev/null +++ b/tests/engine/test_post_only_ws_exec.py @@ -0,0 +1,397 @@ +""" +Post-only over ws-exec — live e2e. + +Engine-side post-only semantics (would-cross/touch matrices, one-tick +placement, resting-maker fills) are proven transport-independently via REST in +tests/engine/. This module pins the ws-exec layer's OWN risk surface +for the feature: + +* request payload mapping + flag fidelity — postOnly=True survives + WsCreateOrderRequest serialization, reaches the REAL engine, and the order + rests with the flag set. The WS CreateOrderResponse model carries no + postOnly echo (status/execQty/cumQty/orderId/clientOrderId only), so flag + fidelity is asserted via REST openOrders read-back; +* business-rejection envelope mapping — POST_ONLY_WOULD_CROSS_ERROR surfaces + as WsExecOperationError with the structured code, NO order is created, and + the counterparty's resting ask is untouched; +* validation envelope, both halves — post_only + IOC raises locally in the + SHARED payload builder before any frame is sent (shared-builder reuse pin), + and a raw correctly-signed frame that bypasses the guard is rejected + server-side with INPUT_VALIDATION_ERROR through the per-op error envelope. + +Gated on the same env as tests/ws_exec/test_ws_exec.py. The engineered +would-cross test additionally needs SPOT_*_2 (counterparty) and a controlled +(empty) book, so it gates on the shared external-liquidity skip helper like +the REST post-only suite does. The raw negative probe reuses the +raw-WebSocket helpers from tests/ws_exec/test_ws_exec.py. +""" + +from __future__ import annotations + +from typing import cast + +import asyncio +import os +import time +import uuid +from dataclasses import dataclass +from decimal import Decimal + +import pytest +import pytest_asyncio +from dotenv import load_dotenv + +from sdk.async_exec_api.order_status import OrderStatus +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.depth import Depth +from sdk.open_api.models.order import Order +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api import ReyaTradingClient +from sdk.reya_rest_api.auth.signatures import OrderTypeInt, TimeInForceInt +from sdk.reya_rest_api.config import TradingConfig +from sdk.reya_rest_api.models.orders import LimitOrderParameters +from sdk.reya_ws_exec import ReyaWsExecClient, WsExecOperationError +from tests.helpers.liquidity_detector import ( + SAFE_NO_MATCH_BUY_PRICE, + SAFE_NO_MATCH_SELL_PRICE, + skip_if_external_liquidity, +) +from tests.helpers.reya_tester.data import DataOperations +from tests.helpers.ws_exec_harness import assert_per_op_error, raw_connect, raw_recv_until, raw_send_envelope + +load_dotenv() + +_REQUIRED_ENV = ( + "REYA_WS_EXEC_URL", + "SPOT_PRIVATE_KEY_1", + "SPOT_ACCOUNT_ID_1", + "SPOT_WALLET_ADDRESS_1", +) +_MISSING_ENV = [_k for _k in _REQUIRED_ENV if not os.environ.get(_k)] + +# The would-cross probe needs a second account to rest the counterparty ask +# (same optionality pattern as the ws_exec harness's spot_rest_2). +_COUNTERPARTY_ENV = ( + "SPOT_PRIVATE_KEY_2", + "SPOT_ACCOUNT_ID_2", + "SPOT_WALLET_ADDRESS_2", +) + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.post_only, + pytest.mark.skipif( + bool(_MISSING_ENV), + reason="ws-exec post-only tests need " + ", ".join(_REQUIRED_ENV) + "; missing: " + ", ".join(_MISSING_ENV), + ), +] + +SPOT_SYMBOL = "WETHRUSD" +# Far-out resting buy on an ETH-priced book (same convention as +# tests/ws_exec/test_ws_exec.py): the post-only GTC rests until cancelled. +REST_BUY_PX = "1" +# Engineered touch for the would-cross probe: counterparty ask and post-only +# buy meet at the safe no-match sell price. A rejection can never take +# liquidity, and any external ask below it would only make the probe MORE +# crossing (same error code), so the touch is deterministic. +WOULD_CROSS_PX = str(SAFE_NO_MATCH_SELL_PRICE) +# The IOC probes price at the safe no-match buy price so even a validation +# regression could not move the book (an IOC buy at $10 cancels unfilled). +IOC_BUY_PX = str(SAFE_NO_MATCH_BUY_PRICE) + + +async def _wait_for_open_order(rest: ReyaTradingClient, order_id: str, timeout_s: float = 10.0) -> Order: + """Poll openOrders until `order_id` appears — the OrdersProvider cache + consumes the ME's Redis stream asynchronously w.r.t. the ws-exec ack.""" + deadline = time.time() + timeout_s + while time.time() < deadline: + open_orders = await rest.get_open_orders() + order = next((o for o in open_orders if o.order_id == order_id), None) + if order is not None: + return order + await asyncio.sleep(0.2) + raise AssertionError(f"Order {order_id} not visible via REST within {timeout_s}s") + + +async def _open_order_ids(rest: ReyaTradingClient) -> set[str]: + """Snapshot of the account's open order ids — for asserting that a + rejected post-only probe left openOrders unchanged.""" + return {str(order.order_id) for order in await rest.get_open_orders()} + + +async def _oracle_price(rest: ReyaTradingClient, symbol: str, max_attempts: int = 5) -> float: + """Oracle price for the liquidity gate, retrying through the transient + NO_PRICES_FOUND_FOR_SYMBOL feed gap (mirrors DataOperations.current_price).""" + last_exc: Exception | None = None + for _ in range(max_attempts): + try: + price = await rest.markets.get_price(symbol) + return float(price.oracle_price) + except ApiException as exc: + if "NO_PRICES_FOUND_FOR_SYMBOL" not in str(exc) and "Price not found" not in str(exc): + raise + last_exc = exc + await asyncio.sleep(0.3) + raise RuntimeError(f"Oracle price for {symbol} unavailable after {max_attempts} attempts") from last_exc + + +class _RestDepthOps: + """Duck-typed stand-in for DataOperations: exposes the one method the + shared liquidity gate calls (`market_depth`) over a bare REST client, so + this module stays off the full ReyaTester fixture stack like the other + ws-exec suites.""" + + def __init__(self, rest: ReyaTradingClient) -> None: + self._rest = rest + + async def market_depth(self, symbol: str) -> Depth: + return await self._rest.markets.get_market_depth(symbol=symbol) + + +@dataclass +class _PostOnlyWsHarness: + """Shared, module-scoped state for the post-only ws-exec flows.""" + + rest: ReyaTradingClient + ws: ReyaWsExecClient + min_qty: str + oracle_symbol: str + + +@pytest_asyncio.fixture(loop_scope="session", scope="module") +async def post_only_ws_harness(): + """A started spot REST client + connected ws-exec client + market metadata. + + Mirrors modify_ws_harness: everything after start() runs inside the try so + a skip (or a connect failure) never leaks the aiohttp session. + """ + config = TradingConfig.from_env_spot(account_number=1) + rest = ReyaTradingClient(config) + await rest.start() + ws: ReyaWsExecClient | None = None + try: + markets = {m.symbol: m for m in await rest.reference.get_spot_market_definitions()} + if SPOT_SYMBOL not in markets: + pytest.skip(f"{SPOT_SYMBOL} not found in /spotMarketDefinitions") + market = markets[SPOT_SYMBOL] + ws = ReyaWsExecClient(rest_client=rest, ws_url=os.environ["REYA_WS_EXEC_URL"]) + await ws.connect() + yield _PostOnlyWsHarness( + rest=rest, + ws=ws, + min_qty=str(market.min_order_qty), + oracle_symbol=f"{market.base_asset}RUSDPERP", + ) + finally: + if ws is not None: + await ws.close() + await rest.close() + + +@pytest_asyncio.fixture(loop_scope="session", scope="module") +async def counterparty_rest(): + """A started REST client for SPOT account 2 — rests the engineered + counterparty ask for the would-cross probe. Skips when SPOT_*_2 is absent.""" + missing = [_k for _k in _COUNTERPARTY_ENV if not os.environ.get(_k)] + if missing: + pytest.skip( + "would-cross counterparty needs " + ", ".join(_COUNTERPARTY_ENV) + "; missing: " + ", ".join(missing) + ) + rest2 = ReyaTradingClient(TradingConfig.from_env_spot(account_number=2)) + await rest2.start() + try: + yield rest2 + finally: + await rest2.close() + + +async def test_ws_exec_post_only_rests_and_reads_back(post_only_ws_harness): # pylint: disable=redefined-outer-name + """Transport concern: request payload mapping + flag fidelity. A + postOnly=True createOrder over ws-exec reaches the real engine and rests + OPEN; the flag round-trips to the resting order via REST openOrders + read-back (WsCreateOrderResponse carries no postOnly echo, so REST is the + only fidelity oracle).""" + h = post_only_ws_harness + + create = await h.ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=REST_BUY_PX, + qty=h.min_qty, + time_in_force=TimeInForce.GTC, + post_only=True, + ) + ) + order_id = create.order_id + assert order_id is not None, f"post-only createOrder OK but missing orderId: {create}" + assert create.status == OrderStatus.OPEN, f"post-only GTC away from the touch must rest OPEN: {create.status}" + + try: + order = await _wait_for_open_order(h.rest, order_id) + assert order.post_only is True, f"postOnly must read back True via REST openOrders: {order.post_only}" + print(f" [ws-exec] post-only rested OPEN with postOnly=True orderId={order_id}") + finally: + await h.ws.cancel_order(order_id=order_id, symbol=SPOT_SYMBOL, account_id=h.rest.config.account_id) + + +async def test_ws_exec_post_only_would_cross_error_envelope( # pylint: disable=redefined-outer-name + post_only_ws_harness, counterparty_rest +): + """Transport concern: business-rejection envelope mapping. A post-only buy + at EXACTLY the counterparty's best ask (touch counts as a cross) is + rejected by the engine and ws-exec maps it to WsExecOperationError with + code POST_ONLY_WOULD_CROSS_ERROR; NO order is created (openOrders + unchanged) and the counterparty's resting ask is untouched.""" + h = post_only_ws_harness + + oracle = await _oracle_price(h.rest, h.oracle_symbol) + await skip_if_external_liquidity( + cast(DataOperations, _RestDepthOps(h.rest)), + SPOT_SYMBOL, + oracle, + reason_prefix="post-only would-cross (ws-exec)", + ) + + before_ids = await _open_order_ids(h.rest) + ask = await counterparty_rest.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=False, + limit_px=WOULD_CROSS_PX, + qty=h.min_qty, + time_in_force=TimeInForce.GTC, + ) + ) + ask_order_id = ask.order_id + assert ask_order_id is not None, "counterparty GTC creation must return an order_id" + + try: + await _wait_for_open_order(counterparty_rest, ask_order_id) + + with pytest.raises(WsExecOperationError) as exc_info: + await h.ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=WOULD_CROSS_PX, + qty=h.min_qty, + time_in_force=TimeInForce.GTC, + post_only=True, + ) + ) + assert ( + exc_info.value.code == "POST_ONLY_WOULD_CROSS_ERROR" + ), f"Expected POST_ONLY_WOULD_CROSS_ERROR, got {exc_info.value.code}" + print(f" [ws-exec] post-only touch @ {WOULD_CROSS_PX} rejected OK code={exc_info.value.code}") + + after_ids = await _open_order_ids(h.rest) + assert after_ids == before_ids, f"Rejected post-only must not create an order: {after_ids - before_ids}" + untouched = next( + (o for o in await counterparty_rest.get_open_orders() if o.order_id == ask_order_id), + None, + ) + assert untouched is not None, "Book must be untouched: the counterparty ask was consumed or cancelled" + assert untouched.limit_px is not None and Decimal(untouched.limit_px) == Decimal(WOULD_CROSS_PX) + assert untouched.qty is not None and Decimal(untouched.qty) == Decimal(h.min_qty) + print(" [ws-exec] openOrders unchanged for the prober; counterparty ask untouched") + finally: + # If a regression let the probe rest, sweep it before the run ends. + for leaked_id in (await _open_order_ids(h.rest)) - before_ids: + await h.ws.cancel_order(order_id=leaked_id, symbol=SPOT_SYMBOL, account_id=h.rest.config.account_id) + try: + await counterparty_rest.cancel_order( + order_id=ask_order_id, symbol=SPOT_SYMBOL, account_id=counterparty_rest.config.account_id + ) + except ApiException: # nosec B110 + pass # ask already gone (e.g. consumed by a regression) — the assertions above report it + + +async def test_ws_exec_post_only_ioc_rejected_by_client_guard( + post_only_ws_harness, +): # pylint: disable=redefined-outer-name + """Transport concern: the shared-builder client guard fires on the WS + path. ReyaWsExecClient.create_limit_order delegates to the SAME + build_create_limit_order_payload as REST, so post_only + IOC raises + locally BEFORE any frame is sent (a regression to a WS-local payload + builder would break this). Nothing reaches the wire or the book.""" + h = post_only_ws_harness + + before_ids = await _open_order_ids(h.rest) + with pytest.raises(ValueError, match="post_only is not supported on IOC orders"): + await h.ws.create_limit_order( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=IOC_BUY_PX, + qty=h.min_qty, + time_in_force=TimeInForce.IOC, + post_only=True, + ) + ) + print(" [ws-exec] client guard refused post_only + IOC locally") + + assert await _open_order_ids(h.rest) == before_ids, "Locally-guarded request must not change openOrders" + + +async def test_ws_exec_post_only_ioc_raw_rejected_envelope( + post_only_ws_harness, +): # pylint: disable=redefined-outer-name + """Transport concern: server-side validation envelope. Bypassing the SDK + guard with a raw post_only=True + IOC createOrder frame — signed over the + EXACT wire values so signature recovery passes and the post-only/IOC rule + is what rejects — comes back as a per-op error envelope with + INPUT_VALIDATION_ERROR. The safety property holds independently of the + client guard (mirrors REST test_post_only_ioc_raw_rejected_by_api).""" + h = post_only_ws_harness + rest = h.rest + assert rest.config.account_id is not None + + nonce = rest.get_next_nonce() + deadline = int(time.time()) + 60 + signature = rest.signature_generator.sign_order( + account_id=rest.config.account_id, + market_id=rest.get_market_id_from_symbol(SPOT_SYMBOL), + exchange_id=rest.config.dex_id, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal(h.min_qty), + limit_price=Decimal(IOC_BUY_PX), + trigger_price=Decimal(0), + time_in_force=int(TimeInForceInt.IOC), + client_order_id=0, + reduce_only=False, + expires_after=0, + nonce=nonce, + deadline=deadline, + post_only=True, + ) + payload = { + "accountId": rest.config.account_id, + "symbol": SPOT_SYMBOL, + "exchangeId": rest.config.dex_id, + "isBuy": True, + "limitPx": IOC_BUY_PX, + "qty": h.min_qty, + "orderType": "LIMIT", + "timeInForce": "IOC", + "postOnly": True, + "expiresAfter": 0, + "signature": signature, + "nonce": str(nonce), + "signerWallet": rest.signer_wallet_address, + "deadline": deadline, + } + + before_ids = await _open_order_ids(rest) + raw_ws = raw_connect(os.environ["REYA_WS_EXEC_URL"]) + try: + env_id = uuid.uuid4().hex[:12] + raw_send_envelope(raw_ws, "createOrder", env_id, payload) + resp = raw_recv_until(raw_ws, lambda f: f.get("id") == env_id and "ok" in f) + err = assert_per_op_error(resp, ("INPUT_VALIDATION_ERROR",), "post_only+IOC raw createOrder") + print(f" [ws-exec] raw post_only+IOC rejected OK code={err.get('error')!r}") + finally: + raw_ws.close() + + assert await _open_order_ids(rest) == before_ids, "Rejected post_only+IOC must not change openOrders" diff --git a/tests/engine/test_self_match_edge_cases.py b/tests/engine/test_self_match_edge_cases.py new file mode 100644 index 00000000..97a2acbb --- /dev/null +++ b/tests/engine/test_self_match_edge_cases.py @@ -0,0 +1,347 @@ +""" +Self-match prevention EDGE choreographies parametrized over [spot, perp]. + +The basics (taker cancelled, maker untouched, cross-account matches fine) +live in test_self_match_prevention.py. This module covers the edge cases +that were historically spot-only (moved from +tests/spot/test_self_match_prevention.py and parametrized — the perp +book is a real second code path, and identifier/SMP interplay broke on perp +before): + +- exact-price boundary (touch counts as a cross → SMP fires) +- non-crossing same-account orders coexist (price compatibility is checked + BEFORE self-match, so the result is "no match", not "self-match") +- non-crossing same-account IOC cancels for NO-MATCH without touching the + resting order +- qty relations: the taker is FULLY cancelled whether smaller or larger + than the self maker (no partial self-fill) +- market-maker shape: multiple non-crossing levels on both sides from one + account all rest +- a taker that would sweep multiple self-orders is cancelled outright +- partial fill against ANOTHER account followed by a would-be self-match + cancels the remainder (the fill stands, the self-order is untouched) +- a same-account non-crossing pair still matches normally against a + different account + +All tests need a controlled (empty) book. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.liquidity_detector import skip_if_external_config_liquidity +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig +from tests.helpers.order_lifecycle import wait_for_taker_perp_execution +from tests.helpers.reya_tester import limit_order_params_to_order + +EDGE_REASON = "Self-match edge choreographies need a controlled (empty) book." + + +def _no_execution_seen(tester: ReyaTester, market_type: str, symbol: str) -> bool: + """True when no execution event for `symbol` has hit the tester's WS + stores this test (the function-scoped fixtures clear WS state).""" + if market_type == "spot": + return tester.ws.last_spot_execution is None + return tester.ws.perp_executions.find_last(lambda e: e.symbol == symbol) is None + + +async def _wait_for_taker_fill(taker: ReyaTester, market_type: str, order_id: str, params) -> None: + """Market-agnostic 'taker order filled' wait.""" + if market_type == "spot": + expected = limit_order_params_to_order(params, taker.account_id) + await taker.wait.for_spot_execution(order_id, expected, timeout=10) + else: + await wait_for_taker_perp_execution(taker, order_id) + + +async def _rest_gtc(tester: ReyaTester, symbol: str, px: str, qty: str, is_buy: bool, market_type: str) -> str: + params = OrderBuilder().symbol(symbol).side(is_buy).price(px).qty(qty).gtc().build() + order_id = await tester.orders.create_limit(params) + assert order_id is not None, f"[{market_type}] expected order_id" + await tester.wait.for_order_creation(order_id) + return order_id + + +async def _open_ids(tester: ReyaTester, symbol: str) -> set[str]: + return {o.order_id for o in await tester.client.get_open_orders() if o.symbol == symbol} + + +@pytest.mark.asyncio +async def test_self_match_exact_price_boundary( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """Buy and sell at the EXACT same price from one account: the touch counts + as a cross, so self-match prevention cancels the taker.""" + await skip_if_external_config_liquidity(market_config, maker, EDGE_REASON) + await maker.orders.close_all(fail_if_none=False) + + px = str(market_config.price(0.97)) + maker_order_id = await _rest_gtc(maker, market_config.symbol, px, market_config.min_qty, False, market_type) + + taker_params = OrderBuilder().symbol(market_config.symbol).buy().price(px).qty(market_config.min_qty).gtc().build() + taker_order_id = await maker.orders.create_limit(taker_params) + + open_ids = await _open_ids(maker, market_config.symbol) + assert taker_order_id not in open_ids, f"[{market_type}] taker must be cancelled at the touch" + assert maker_order_id in open_ids, f"[{market_type}] maker must remain" + assert _no_execution_seen(maker, market_type, market_config.symbol), f"[{market_type}] no self-fill" + + await maker.client.cancel_order(symbol=market_config.symbol, account_id=maker.account_id, order_id=maker_order_id) + await maker.wait.for_order_state(maker_order_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_non_crossing_orders_no_self_match( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """Non-crossing same-account orders coexist — price compatibility is + checked BEFORE self-match, so both rest.""" + await skip_if_external_config_liquidity(market_config, maker, EDGE_REASON) + await maker.orders.close_all(fail_if_none=False) + + sell_id = await _rest_gtc( + maker, market_config.symbol, str(market_config.price(1.02)), market_config.min_qty, False, market_type + ) + buy_id = await _rest_gtc( + maker, market_config.symbol, str(market_config.price(0.99)), market_config.min_qty, True, market_type + ) + + open_ids = await _open_ids(maker, market_config.symbol) + assert sell_id in open_ids, f"[{market_type}] non-crossing sell must rest" + assert buy_id in open_ids, f"[{market_type}] non-crossing buy must rest" + + await maker.client.mass_cancel(symbol=market_config.symbol, account_id=maker.account_id) + await maker.wait.for_order_state(sell_id, OrderStatus.CANCELLED) + await maker.wait.for_order_state(buy_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_non_crossing_ioc_cancelled_no_match( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """A non-crossing same-account IOC cancels for NO-MATCH (not self-match) + and leaves the resting order untouched.""" + await skip_if_external_config_liquidity(market_config, maker, EDGE_REASON) + await maker.orders.close_all(fail_if_none=False) + + sell_id = await _rest_gtc( + maker, market_config.symbol, str(market_config.price(1.04)), market_config.min_qty, False, market_type + ) + + ioc_params = ( + OrderBuilder() + .symbol(market_config.symbol) + .buy() + .price(str(market_config.price(0.96))) + .qty(market_config.min_qty) + .ioc() + .build() + ) + await maker.orders.create_limit(ioc_params) + + open_ids = await _open_ids(maker, market_config.symbol) + assert sell_id in open_ids, f"[{market_type}] resting sell must survive the no-match IOC" + assert _no_execution_seen(maker, market_type, market_config.symbol), f"[{market_type}] no execution" + + await maker.client.cancel_order(symbol=market_config.symbol, account_id=maker.account_id, order_id=sell_id) + await maker.wait.for_order_state(sell_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("taker_factor", [1, 2], ids=["smaller_taker", "larger_taker"]) +async def test_self_match_taker_fully_cancelled_regardless_of_qty( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker_factor: int, +) -> None: + """Self-match cancels the ENTIRE taker whether it is smaller than or + larger than the resting self maker — there is never a partial self-fill + and the maker's qty is untouched.""" + await skip_if_external_config_liquidity(market_config, maker, EDGE_REASON) + await maker.orders.close_all(fail_if_none=False) + + maker_qty = str(Decimal(market_config.min_qty) * 2) + taker_qty = str(Decimal(market_config.min_qty) * taker_factor) + px = str(market_config.price(0.97)) + cross_px = str(round(market_config.price(0.97) * 1.01, 2)) + + maker_order_id = await _rest_gtc(maker, market_config.symbol, px, maker_qty, False, market_type) + + taker_params = OrderBuilder().symbol(market_config.symbol).buy().price(cross_px).qty(taker_qty).gtc().build() + taker_order_id = await maker.orders.create_limit(taker_params) + + open_orders = await maker.client.get_open_orders() + open_ids = {o.order_id for o in open_orders if o.symbol == market_config.symbol} + assert taker_order_id not in open_ids, f"[{market_type}] taker must be fully cancelled" + assert maker_order_id in open_ids, f"[{market_type}] maker must remain" + maker_order = next(o for o in open_orders if o.order_id == maker_order_id) + assert maker_order.qty is not None + assert Decimal(maker_order.qty) == Decimal(maker_qty), f"[{market_type}] maker qty must be untouched" + assert _no_execution_seen(maker, market_type, market_config.symbol), f"[{market_type}] no self-fill" + + await maker.client.cancel_order(symbol=market_config.symbol, account_id=maker.account_id, order_id=maker_order_id) + await maker.wait.for_order_state(maker_order_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_market_maker_multiple_non_crossing_levels( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """One account quoting three non-crossing levels per side: all six orders + rest (historically a problematic shape).""" + await skip_if_external_config_liquidity(market_config, maker, EDGE_REASON) + await maker.orders.close_all(fail_if_none=False) + + sell_prices = [round(market_config.oracle_price * (1.02 + i * 0.02), 2) for i in range(3)] + buy_prices = [round(market_config.oracle_price * (0.98 - i * 0.02), 2) for i in range(3)] + + placed: list[str] = [] + for px in sell_prices: + placed.append(await _rest_gtc(maker, market_config.symbol, str(px), market_config.min_qty, False, market_type)) + for px in buy_prices: + placed.append(await _rest_gtc(maker, market_config.symbol, str(px), market_config.min_qty, True, market_type)) + + open_ids = await _open_ids(maker, market_config.symbol) + for order_id in placed: + assert order_id in open_ids, f"[{market_type}] level {order_id} must rest" + + await maker.client.mass_cancel(symbol=market_config.symbol, account_id=maker.account_id) + for order_id in placed: + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_multiple_self_matches_in_sequence( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """A taker that would sweep THREE self-orders is cancelled outright on the + first self-match; all makers remain.""" + await skip_if_external_config_liquidity(market_config, maker, EDGE_REASON) + await maker.orders.close_all(fail_if_none=False) + + base = market_config.price(0.97) + maker_ids = [] + for i in range(3): + px = str(round(base * (1 + i * 0.01), 2)) + maker_ids.append(await _rest_gtc(maker, market_config.symbol, px, market_config.min_qty, False, market_type)) + + sweep_px = str(round(base * 1.10, 2)) + sweep_qty = str(Decimal(market_config.min_qty) * 3) + taker_params = OrderBuilder().symbol(market_config.symbol).buy().price(sweep_px).qty(sweep_qty).gtc().build() + taker_order_id = await maker.orders.create_limit(taker_params) + + open_ids = await _open_ids(maker, market_config.symbol) + assert taker_order_id not in open_ids, f"[{market_type}] sweeping taker must be cancelled" + for order_id in maker_ids: + assert order_id in open_ids, f"[{market_type}] maker {order_id} must remain" + assert _no_execution_seen(maker, market_type, market_config.symbol), f"[{market_type}] no self-fill" + + await maker.client.mass_cancel(symbol=market_config.symbol, account_id=maker.account_id) + for order_id in maker_ids: + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_partial_fill_then_self_match_cancels_remainder( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """Taker fills against ANOTHER account first, then would self-match — the + fill stands, the remainder is cancelled, the self-order is untouched. + + On perp this drives a real fill mid-sequence (settlement path engaged); + the perp fixtures restore both accounts' position deltas afterwards. + """ + await skip_if_external_config_liquidity(market_config, maker, EDGE_REASON) + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + + px = str(market_config.price(0.97)) + fill_qty = market_config.min_qty + sweep_qty = str(Decimal(fill_qty) * 2) + + # Account A (maker fixture) rests the bid that WILL fill. + account_a_buy = await _rest_gtc(maker, market_config.symbol, px, fill_qty, True, market_type) + # Account B (taker fixture) rests its own bid at the same price. + account_b_buy = await _rest_gtc(taker, market_config.symbol, px, fill_qty, True, market_type) + + # Account B sells 2x: leg 1 fills A's bid (FIFO: A rested first), leg 2 + # would hit B's own bid → self-match → remainder cancelled. + sell_params = OrderBuilder().symbol(market_config.symbol).sell().price(px).qty(sweep_qty).gtc().build() + account_b_sell = await taker.orders.create_limit(sell_params) + assert account_b_sell is not None + + await _wait_for_taker_fill(taker, market_type, account_b_sell, sell_params) + await maker.wait.for_order_state(account_a_buy, OrderStatus.FILLED, timeout=10) + + taker_open = await _open_ids(taker, market_config.symbol) + assert account_b_sell not in taker_open, f"[{market_type}] remainder must be cancelled after self-match" + assert account_b_buy in taker_open, f"[{market_type}] B's own bid must be untouched" + open_orders = await taker.client.get_open_orders() + b_buy = next(o for o in open_orders if o.order_id == account_b_buy) + assert b_buy.qty is not None + assert Decimal(b_buy.qty) == Decimal(fill_qty), f"[{market_type}] B's bid qty must be unchanged" + + await taker.client.cancel_order(symbol=market_config.symbol, account_id=taker.account_id, order_id=account_b_buy) + await taker.wait.for_order_state(account_b_buy, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_non_crossing_orders_can_match_other_accounts( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """A same-account non-crossing pair still matches normally against a + DIFFERENT account: the crossed side fills, the far side keeps resting.""" + await skip_if_external_config_liquidity(market_config, maker, EDGE_REASON) + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + + a_sell = await _rest_gtc( + maker, market_config.symbol, str(market_config.price(1.04)), market_config.min_qty, False, market_type + ) + a_buy = await _rest_gtc( + maker, market_config.symbol, str(market_config.price(0.97)), market_config.min_qty, True, market_type + ) + + ioc_params = ( + OrderBuilder() + .symbol(market_config.symbol) + .sell() + .price(str(market_config.price(0.96))) + .qty(market_config.min_qty) + .ioc() + .build() + ) + taker_order_id = await taker.orders.create_limit(ioc_params) + assert taker_order_id is not None + + await _wait_for_taker_fill(taker, market_type, taker_order_id, ioc_params) + await maker.wait.for_order_state(a_buy, OrderStatus.FILLED, timeout=10) + + open_ids = await _open_ids(maker, market_config.symbol) + assert a_sell in open_ids, f"[{market_type}] the non-crossed sell must keep resting" + + await maker.client.cancel_order(symbol=market_config.symbol, account_id=maker.account_id, order_id=a_sell) + await maker.wait.for_order_state(a_sell, OrderStatus.CANCELLED) diff --git a/tests/test_orderbook/test_self_match_prevention.py b/tests/engine/test_self_match_prevention.py similarity index 98% rename from tests/test_orderbook/test_self_match_prevention.py rename to tests/engine/test_self_match_prevention.py index 6a8bc670..52a31eba 100644 --- a/tests/test_orderbook/test_self_match_prevention.py +++ b/tests/engine/test_self_match_prevention.py @@ -23,9 +23,8 @@ from sdk.open_api.models.time_in_force import TimeInForce from sdk.reya_rest_api.models import LimitOrderParameters from tests.helpers import ReyaTester +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig from tests.helpers.reya_tester import logger -from tests.test_orderbook.conftest import PerpTestConfig -from tests.test_spot.spot_config import SpotTestConfig async def _skip_if_external_liquidity(market_config: SpotTestConfig | PerpTestConfig, tester: ReyaTester) -> None: diff --git a/tests/test_orderbook/test_websocket_events.py b/tests/engine/test_websocket_events.py similarity index 95% rename from tests/test_orderbook/test_websocket_events.py rename to tests/engine/test_websocket_events.py index a6ecd9fa..7f363d63 100644 --- a/tests/test_orderbook/test_websocket_events.py +++ b/tests/engine/test_websocket_events.py @@ -4,7 +4,7 @@ The matching engine emits the same ``orderChanges`` events for both market types under v2.3.0 — these tests verify create/cancel events fire and carry the expected payload. Balance-update verification stays in -``tests/test_spot/`` because perp markets don't change asset balances on +``tests/spot/`` because perp markets don't change asset balances on match (positions accrue instead). """ @@ -18,8 +18,7 @@ from sdk.open_api.models.time_in_force import TimeInForce from sdk.reya_rest_api.models import LimitOrderParameters from tests.helpers import ReyaTester -from tests.test_orderbook.conftest import PerpTestConfig -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import PerpTestConfig, SpotTestConfig @pytest.mark.asyncio diff --git a/tests/helpers/builders/__init__.py b/tests/helpers/builders/__init__.py index 74ad0867..51810040 100644 --- a/tests/helpers/builders/__init__.py +++ b/tests/helpers/builders/__init__.py @@ -1,5 +1,5 @@ """Order builders for creating test orders with fluent API.""" -from .order_builder import OrderBuilder +from .order_builder import OrderBuilder, TriggerOrderBuilder, full_state_modify_params -__all__ = ["OrderBuilder"] +__all__ = ["OrderBuilder", "TriggerOrderBuilder", "full_state_modify_params"] diff --git a/tests/helpers/builders/order_builder.py b/tests/helpers/builders/order_builder.py index 037fcb99..eb593878 100644 --- a/tests/helpers/builders/order_builder.py +++ b/tests/helpers/builders/order_builder.py @@ -28,16 +28,62 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from dataclasses import dataclass, field +from sdk.open_api.models.order import Order from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.side import Side from sdk.open_api.models.time_in_force import TimeInForce -from sdk.reya_rest_api.models import LimitOrderParameters, TriggerOrderParameters +from sdk.reya_rest_api.models import LimitOrderParameters, ModifyOrderParameters, TriggerOrderParameters if TYPE_CHECKING: - from tests.test_spot.spot_config import SpotTestConfig + from tests.helpers.market_config import SpotTestConfig + + +def full_state_modify_params(order: Order, **overrides: Any) -> ModifyOrderParameters: + """Build the COMPLETE post-modify state for a fetched resting order. + + ``modifyOrder`` has no omitted-means-inherited shorthand: the four + modifiable fields (``limit_px``, ``qty``, ``post_only``, ``expires_after``) + must all carry the full intended post-modify state, and the signed + immutables (``is_buy``, ``time_in_force``, ``trigger_px``, ``reduce_only``) + must restate the resting order's values. This helper restates everything + from the fetched :class:`Order`, targets it by ``order_id``, then applies + ``overrides`` — so a test only spells out what it actually changes:: + + order = await tester.data.open_order(order_id) + params = full_state_modify_params(order, limit_px="2950", qty="0.75") + + Overriding ``client_order_id`` switches targeting to it (clearing + ``order_id``) unless ``order_id`` is also explicitly overridden, keeping + the builder's exactly-one-target rule satisfied by default. + + ``resting_client_order_id`` stays at the dataclass default (0) unless + overridden — the public ``Order`` model doesn't expose the resting + clientOrderId, so tests that created the order with a non-zero + clientOrderId must pass it explicitly. + """ + if order.qty is None: + raise ValueError(f"Order {order.order_id} has no qty; cannot restate the post-modify state") + + fields: dict[str, Any] = { + "symbol": order.symbol, + "is_buy": order.side == Side.B, + "limit_px": order.limit_px, + "qty": order.qty, + "post_only": bool(order.post_only) if order.post_only is not None else False, + "expires_after": order.expires_after if order.expires_after is not None else 0, + "time_in_force": order.time_in_force if order.time_in_force is not None else TimeInForce.GTC, + "order_id": int(order.order_id), + "trigger_px": order.trigger_px, + "reduce_only": bool(order.reduce_only) if order.reduce_only is not None else False, + } + if "client_order_id" in overrides and "order_id" not in overrides: + fields["order_id"] = None + fields.update(overrides) + return ModifyOrderParameters(**fields) @dataclass @@ -61,6 +107,7 @@ class OrderBuilder: _limit_px: str = "4000.0" _time_in_force: TimeInForce = field(default_factory=lambda: TimeInForce.GTC) _reduce_only: bool | None = None + _post_only: bool | None = None _expires_after: int | None = None _client_order_id: int | None = None @@ -154,6 +201,20 @@ def ioc(self) -> OrderBuilder: self._time_in_force = TimeInForce.IOC return self + def gtt(self, expires_after: int | None = None) -> OrderBuilder: + """Set time-in-force to Good-Till-Time (rests until it auto-expires). + + GTT is the ONLY time-in-force that carries an order lifetime: the SDK + requires a non-zero ``expires_after`` strictly after the deadline (GTC + rests until cancelled and IOC never rests, so both sign ``expires_after`` + 0). Pass it here, or set it separately via :meth:`expires_after`; either + way the build is rejected without one. + """ + self._time_in_force = TimeInForce.GTT + if expires_after is not None: + self._expires_after = expires_after + return self + def time_in_force(self, tif: TimeInForce) -> OrderBuilder: """Set time-in-force explicitly.""" self._time_in_force = tif @@ -164,8 +225,18 @@ def reduce_only(self, value: bool = True) -> OrderBuilder: self._reduce_only = value return self + def post_only(self, value: bool = True) -> OrderBuilder: + """Set the post-only (maker-only) flag (GTC only; rejected on IOC).""" + self._post_only = value + return self + def expires_after(self, timestamp_ms: int) -> OrderBuilder: - """Set expiration timestamp in milliseconds (IOC orders only).""" + """Set the on-chain order lifetime (``expiresAfter``). + + Only GTT carries a lifetime, and the SDK requires it non-zero and + strictly after the deadline; GTC and IOC must sign ``expiresAfter`` 0 + (GTC rests until cancelled, IOC never rests). Use with :meth:`gtt`. + """ self._expires_after = timestamp_ms return self @@ -188,6 +259,7 @@ def build(self) -> LimitOrderParameters: limit_px=self._limit_px, time_in_force=self._time_in_force, reduce_only=self._reduce_only, + post_only=self._post_only, expires_after=self._expires_after, client_order_id=self._client_order_id, ) @@ -205,6 +277,7 @@ def copy(self) -> OrderBuilder: "_limit_px", "_time_in_force", "_reduce_only", + "_post_only", "_expires_after", "_client_order_id", ]: diff --git a/tests/helpers/liquidity_detector.py b/tests/helpers/liquidity_detector.py index 8769fb3c..8f133dcf 100644 --- a/tests/helpers/liquidity_detector.py +++ b/tests/helpers/liquidity_detector.py @@ -331,7 +331,7 @@ async def skip_if_external_liquidity( ) -> None: """Skip the calling pytest test if any external liquidity is on the orderbook. - Mirrors the pattern used by `tests/test_spot/test_maker_taker_matching.py` + Mirrors the pattern used by `tests/spot/test_maker_taker_matching.py` (which calls ``pytest.skip`` when ``spot_config.has_any_external_liquidity`` is true): tests that need to *rest* a maker order at oracle ±1% and then cross it with a same-side taker only work in a controlled environment. @@ -366,3 +366,16 @@ async def skip_if_external_liquidity( "any liquidity within the ±5% circuit-breaker band rather than " "resting on the book. Rerun against a controlled environment." ) + + +async def skip_if_external_config_liquidity(config, tester, reason: str) -> None: + """Config-flavoured twin of `skip_if_external_liquidity` for tests that + hold a `MarketTestConfig`: refreshes the config's cached book via the + tester's data ops and skips when ANY external liquidity is present. + + Use the positional variant above when you only have data-ops + symbol + + oracle price (no config object). + """ + await config.refresh_order_book(tester.data) + if config.has_any_external_liquidity: + pytest.skip(f"Skipping: external liquidity exists. {reason}") diff --git a/tests/test_spot/spot_config.py b/tests/helpers/market_config.py similarity index 85% rename from tests/test_spot/spot_config.py rename to tests/helpers/market_config.py index da86f860..73b5ca58 100644 --- a/tests/test_spot/spot_config.py +++ b/tests/helpers/market_config.py @@ -1,11 +1,19 @@ -"""Centralized SPOT test configuration with smart liquidity detection. +"""Centralized per-market test configuration with smart liquidity detection. This module provides: -1. SpotMarketConfig - Immutable market configuration fetched from API -2. SpotTestConfig - Test configuration with dynamic state and liquidity detection +1. SpotMarketConfig - Immutable spot market configuration fetched from API +2. MarketTestConfig - Test configuration with dynamic state and liquidity + detection, shared by BOTH market types. ``SpotTestConfig`` and + ``PerpTestConfig`` are compatibility aliases: the two classes were + historically separate (PerpTestConfig lived in tests/engine/ + conftest.py mirroring SpotTestConfig's shape) but method-for-method + identical, so they were unified when this module was promoted to + tests/helpers/. 3. fetch_spot_market_configs() - Fetches market configs from /v2/spotMarketDefinitions -The config is injected via pytest fixtures - no global state is used. +The config is injected via pytest fixtures (``spot_config`` / +``perp_market_config`` / the parametrized ``market_config``) - no global +state is used. Usage in test files: @pytest.mark.asyncio @@ -124,17 +132,20 @@ def get_available_assets(configs: dict[str, SpotMarketConfig]) -> list[str]: @dataclass -class SpotTestConfig: +class MarketTestConfig: """ - Centralized configuration for SPOT tests with smart liquidity detection. + Centralized per-market test configuration with smart liquidity detection. This dataclass is populated by a pytest fixture at session start, - ensuring all tests use consistent, dynamically-fetched values. + ensuring all tests use consistent, dynamically-fetched values. The same + class serves spot markets (via the ``spot_config`` fixture) and perp + markets (via ``perp_market_config``) — the liquidity-detection surface + and price helpers are market-type-independent. Attributes: - symbol: The spot market symbol (e.g., "WETHRUSD", "WBTCRUSD") - market_id: The on-chain market ID (e.g., 5 for ETH, 11 for BTC) - min_qty: Minimum order quantity as string (e.g., "0.001" for ETH, "0.0001" for BTC) + symbol: The market symbol (e.g., "WETHRUSD", "ETHRUSDPERP") + market_id: The market ID (spot: on-chain id; perp: raw id) + min_qty: Minimum order quantity as string (e.g., "0.001" for ETH) qty_step_size: Quantity step size for orders oracle_price: Current oracle price for the underlying asset base_asset: The base asset symbol (e.g., "ETH", "BTC") - used for balance checks @@ -148,6 +159,7 @@ class SpotTestConfig: oracle_price: float base_asset: str min_balance: float + tick_size: str = "0" _order_book: OrderBookState | None = field(default=None, repr=False) def price(self, multiplier: float = 1.0) -> float: @@ -283,3 +295,11 @@ def get_safe_no_match_sell_price(self) -> Decimal: Returns ``SAFE_NO_MATCH_SELL_PRICE`` - a high price that will never match. """ return SAFE_NO_MATCH_SELL_PRICE + + +# Compatibility aliases — the historically-separate spot/perp config classes +# were method-for-method identical and are now one class. Existing signatures +# (`SpotTestConfig | PerpTestConfig` unions, `MarketConfig`) all resolve here. +SpotTestConfig = MarketTestConfig +PerpTestConfig = MarketTestConfig +MarketConfig = MarketTestConfig diff --git a/tests/helpers/order_lifecycle.py b/tests/helpers/order_lifecycle.py new file mode 100644 index 00000000..c3a85c58 --- /dev/null +++ b/tests/helpers/order_lifecycle.py @@ -0,0 +1,291 @@ +"""Shared order-lifecycle helpers for the live e2e suites. + +Resting helpers (`rest_spot_gtc` / `rest_perp_gtc`), openOrders/WS pollers +(`wait_for_order_fields` / `wait_for_ws_order_change` — the OrdersProvider +cache consumes the matching engine's Redis stream asynchronously w.r.t. +responses, so read-backs must poll, never single-shot), execution pollers +(`wait_for_taker_spot_execution` / `wait_for_taker_perp_execution`), and +numeric assertion helpers. Promoted from the modify suite's modify_helpers.py +when the modify, post-only and orderbook suites all needed them.""" + +from __future__ import annotations + +import asyncio +import time +from decimal import Decimal + +from sdk.async_api.order import Order as AsyncOrder +from sdk.open_api.models.order import Order +from sdk.open_api.models.perp_execution import PerpExecution +from sdk.open_api.models.spot_execution import SpotExecution +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.market_config import MarketTestConfig, SpotTestConfig + + +async def rest_spot_gtc( + tester: ReyaTester, + spot_config: SpotTestConfig, + price_multiplier: float = 0.96, + qty: str | None = None, + is_buy: bool = True, + client_order_id: int | None = None, +) -> Order: + """Place a GTC away from the touch, wait for creation, return the fetched Order.""" + builder = ( + OrderBuilder.from_config(spot_config) + .side(is_buy) + .price(str(spot_config.price(price_multiplier))) + .qty(qty if qty is not None else spot_config.min_qty) + .gtc() + ) + if client_order_id is not None: + builder = builder.client_order_id(client_order_id) + order_id = await tester.orders.create_limit(builder.build()) + assert order_id is not None, "GTC creation must return an order_id" + await tester.wait.for_order_creation(order_id) + # for_order_creation may return on the WS-only path before the REST + # openOrders cache (OrdersProvider) reflects the order — poll, never + # single-shot. + return await wait_for_order_fields(tester, order_id) + + +async def rest_perp_gtc( + tester: ReyaTester, + symbol: str, + price: str, + qty: str, + is_buy: bool = True, + client_order_id: int | None = None, +) -> Order: + """Perp twin of `rest_spot_gtc`: place a GTC at the given price, wait for + creation, return the fetched Order. Callers pick a price safely away from + the touch (e.g. 0.50x oracle for a buy).""" + builder = OrderBuilder().symbol(symbol).side(is_buy).price(price).qty(qty).gtc() + if client_order_id is not None: + builder = builder.client_order_id(client_order_id) + order_id = await tester.orders.create_limit(builder.build()) + assert order_id is not None, "GTC creation must return an order_id" + await tester.wait.for_order_creation(order_id) + return await wait_for_order_fields(tester, order_id) + + +async def rest_gtc( + tester: ReyaTester, + market_config: MarketTestConfig, + *, + price_multiplier: float, + is_buy: bool = True, + qty: str | None = None, + post_only: bool = False, + client_order_id: int | None = None, +) -> Order: + """Market-agnostic GTC rest for [spot, perp]-parametrized tests: place at + ``market_config.price(multiplier)`` on the config's symbol, wait for + creation, return the fetched Order. Unifies the `rest_spot_gtc` / + `rest_perp_gtc` split.""" + builder = ( + OrderBuilder() + .symbol(market_config.symbol) + .side(is_buy) + .price(str(market_config.price(price_multiplier))) + .qty(qty if qty is not None else market_config.min_qty) + .gtc() + ) + if post_only: + builder = builder.post_only() + if client_order_id is not None: + builder = builder.client_order_id(client_order_id) + order_id = await tester.orders.create_limit(builder.build()) + assert order_id is not None, "GTC creation must return an order_id" + await tester.wait.for_order_creation(order_id) + return await wait_for_order_fields(tester, order_id) + + +async def rest_gtt( + tester: ReyaTester, + market_config: MarketTestConfig, + *, + expires_after: int, + price_multiplier: float = 1.0, + price: str | None = None, + is_buy: bool = True, + qty: str | None = None, + post_only: bool = False, + client_order_id: int | None = None, +) -> Order: + """Market-agnostic GTT rest for [spot, perp]-parametrized tests: place at an + explicit ``price`` (or ``market_config.price(multiplier)`` when omitted) with + a non-zero ``expires_after`` (strictly after the entry deadline), wait for + creation, return the fetched Order. GTT rests like GTC but the matching + engine auto-reaps it at ``expires_after`` — pass a comfortably-future value + (default client deadline is now+60s) for tests that must not expire mid-run. + Pass an absolute ``price`` (e.g. the re-fetched current mark) for crossing + tests where the stale session-config price would drift off the perp band.""" + px = price if price is not None else str(market_config.price(price_multiplier)) + builder = ( + OrderBuilder() + .symbol(market_config.symbol) + .side(is_buy) + .price(px) + .qty(qty if qty is not None else market_config.min_qty) + .gtt(expires_after) + ) + if post_only: + builder = builder.post_only() + if client_order_id is not None: + builder = builder.client_order_id(client_order_id) + order_id = await tester.orders.create_limit(builder.build()) + assert order_id is not None, "GTT creation must return an order_id" + await tester.wait.for_order_creation(order_id) + return await wait_for_order_fields(tester, order_id) + + +async def wait_for_taker_execution( + tester: ReyaTester, market_type: str, taker_order_id: str, timeout_s: float = 10.0 +) -> SpotExecution | PerpExecution: + """Dispatch to the spot/perp taker-execution poller by market type. Both + execution models expose `maker_order_id` / `qty` / `price`, so the caller's + assertions are market-agnostic.""" + if market_type == "spot": + return await wait_for_taker_spot_execution(tester, taker_order_id, timeout_s) + return await wait_for_taker_perp_execution(tester, taker_order_id, timeout_s) + + +async def wait_for_taker_spot_execution( + tester: ReyaTester, taker_order_id: str, timeout_s: float = 10.0 +) -> SpotExecution: + """Poll the tester's wallet spot executions for the taker order's fill. + + Returns the FIRST (most recent endpoint ordering aside, matched by + order_id) execution belonging to `taker_order_id` so callers can assert + on `maker_order_id` — the queue-priority signal. + """ + assert tester.owner_wallet_address is not None + deadline = time.time() + timeout_s + while time.time() < deadline: + executions = await tester.client.wallet.get_wallet_spot_executions(address=tester.owner_wallet_address) + matched = [e for e in (executions.data or []) if str(e.order_id) == str(taker_order_id)] + if matched: + return matched[-1] # endpoint returns newest-first; [-1] is the FIRST fill of this order + await asyncio.sleep(0.2) + raise AssertionError(f"No spot execution for taker order {taker_order_id} within {timeout_s}s") + + +async def wait_for_taker_perp_execution( + tester: ReyaTester, taker_order_id: str, timeout_s: float = 10.0 +) -> PerpExecution: + """Perp twin of `wait_for_taker_spot_execution`: poll the wallet perp + executions for the taker order's fill (matched on takerOrderId) so callers + can assert on `maker_order_id`.""" + assert tester.owner_wallet_address is not None + deadline = time.time() + timeout_s + while time.time() < deadline: + executions = await tester.client.wallet.get_wallet_perp_executions(address=tester.owner_wallet_address) + matched = [e for e in (executions.data or []) if str(e.taker_order_id) == str(taker_order_id)] + if matched: + return matched[-1] # endpoint returns newest-first; [-1] is the FIRST fill of this order + await asyncio.sleep(0.2) + raise AssertionError(f"No perp execution for taker order {taker_order_id} within {timeout_s}s") + + +def assert_px_qty(order: Order, expected_px: str, expected_qty: str) -> None: + """Numeric (Decimal) comparison — the API may normalize trailing zeros.""" + assert order.limit_px is not None and order.qty is not None + assert Decimal(order.limit_px) == Decimal(expected_px), f"limitPx {order.limit_px} != expected {expected_px}" + assert Decimal(order.qty) == Decimal(expected_qty), f"qty {order.qty} != expected {expected_qty}" + + +def double_qty(spot_config: SpotTestConfig) -> str: + return str(Decimal(spot_config.min_qty) * 2) + + +def _order_fields_match( + order: Order, + limit_px: str | None, + qty: str | None, + post_only: bool | None, + expires_after: int | None, + cum_qty: str | None, +) -> bool: + if limit_px is not None and (order.limit_px is None or Decimal(order.limit_px) != Decimal(limit_px)): + return False + if qty is not None and (order.qty is None or Decimal(order.qty) != Decimal(qty)): + return False + if post_only is not None and bool(order.post_only) != post_only: + return False + if expires_after is not None and int(order.expires_after or 0) != expires_after: + return False + if cum_qty is not None and (order.cum_qty is None or Decimal(order.cum_qty) != Decimal(cum_qty)): + return False + return True + + +async def wait_for_order_fields( + tester: ReyaTester, + order_id: str, + *, + limit_px: str | None = None, + qty: str | None = None, + post_only: bool | None = None, + expires_after: int | None = None, + cum_qty: str | None = None, + timeout_s: float = 10.0, +) -> Order: + """Poll openOrders until `order_id` reflects the expected state, then + return the fetched Order. + + The openOrders view is served from the OrdersProvider cache, which + consumes the matching engine's Redis stream asynchronously w.r.t. the + modify/create response — a single-shot read right after the response can + briefly see the pre-modify state. Only the provided expectations are + checked (Decimal compare for px/qty/cumQty).""" + deadline = time.time() + timeout_s + last: Order | None = None + while time.time() < deadline: + order = await tester.data.open_order(order_id) + if order is not None: + last = order + if _order_fields_match(order, limit_px, qty, post_only, expires_after, cum_qty): + return order + await asyncio.sleep(0.2) + raise AssertionError( + f"Order {order_id} did not reach the expected state " + f"(px={limit_px} qty={qty} postOnly={post_only} expiresAfter={expires_after} cumQty={cum_qty}) " + f"within {timeout_s}s; last seen: " + + ( + f"px={last.limit_px} qty={last.qty} postOnly={last.post_only} " + f"expiresAfter={last.expires_after} cumQty={last.cum_qty}" + if last is not None + else "order not in openOrders" + ) + ) + + +async def wait_for_ws_order_change( + tester: ReyaTester, + order_id: str, + *, + limit_px: str, + qty: str, + timeout_s: float = 10.0, +) -> AsyncOrder: + """Poll the wallet orderChanges WS store until the modify's order change + arrives — SAME orderId carrying the NEW px/qty.""" + deadline = time.time() + timeout_s + while time.time() < deadline: + ws_order = tester.ws.orders.get(str(order_id)) + if ( + ws_order is not None + and Decimal(ws_order.limit_px) == Decimal(limit_px) + and ws_order.qty is not None + and Decimal(ws_order.qty) == Decimal(qty) + ): + assert ws_order.order_id == str(order_id), f"WS orderChange id mismatch: {ws_order.order_id}" + return ws_order + await asyncio.sleep(0.1) + last_seen = tester.ws.orders.get(str(order_id)) + raise AssertionError( + f"No WS orderChange with px={limit_px} qty={qty} for order {order_id} within {timeout_s}s; " + f"last seen: {last_seen}" + ) diff --git a/tests/helpers/reya_tester/checks.py b/tests/helpers/reya_tester/checks.py index 9470d275..5355d2e5 100644 --- a/tests/helpers/reya_tester/checks.py +++ b/tests/helpers/reya_tester/checks.py @@ -5,6 +5,7 @@ import asyncio import logging import os +from decimal import Decimal import pytest @@ -184,6 +185,43 @@ async def position( pos.last_trade_sequence_number == expected_last_trade_sequence_number ), "check_position: Last trade sequence number does not match" + async def position_delta( + self, + symbol: str, + baseline: Decimal, + expected_delta: Decimal, + expected_account_id: Optional[int] = None, + expected_exchange_id: Optional[int] = None, + timeout: float = 3.0, + ) -> None: + """Assert the signed position moved by exactly `expected_delta` from `baseline`. + + Perp tests are baseline-relative: accounts may carry pre-existing + positions, so tests assert the signed move their own trades caused + rather than absolute position state. Polls briefly to absorb the + indexer-write lag (same rationale as `position`). When the expected + final signed quantity is non-zero, the position record's identity + fields are verified too. + """ + expected = baseline + expected_delta + current = await self._t.positions.signed_qty(symbol) + deadline = asyncio.get_event_loop().time() + timeout + while current != expected and asyncio.get_event_loop().time() < deadline: + await asyncio.sleep(0.1) + current = await self._t.positions.signed_qty(symbol) + assert current == expected, ( + f"check_position_delta: expected signed qty {expected} " + f"(baseline {baseline} + delta {expected_delta}), got {current}" + ) + + if expected != 0: + pos = await self._t.data.position(symbol) + assert pos is not None, "check_position_delta: non-zero signed qty but no position record" + if expected_account_id is not None: + assert pos.account_id == expected_account_id, "check_position_delta: Account ID does not match" + if expected_exchange_id is not None: + assert pos.exchange_id == expected_exchange_id, "check_position_delta: Exchange ID does not match" + async def position_not_open(self, symbol: str) -> None: """Assert position is closed via both REST and WebSocket.""" pos = await self._t.data.position(symbol) diff --git a/tests/helpers/reya_tester/data.py b/tests/helpers/reya_tester/data.py index c67e1991..86603b88 100644 --- a/tests/helpers/reya_tester/data.py +++ b/tests/helpers/reya_tester/data.py @@ -134,7 +134,7 @@ async def market_depth(self, symbol: str) -> Depth: async def market_definition(self, symbol: str) -> MarketDefinition: """Get market configuration for a specific symbol.""" - markets_config: list[MarketDefinition] = await self._t.client.reference.get_market_definitions() + markets_config: list[MarketDefinition] = await self._t.client.reference.get_perp_market_definitions() for config in markets_config: if config.symbol == symbol: return config diff --git a/tests/helpers/reya_tester/positions.py b/tests/helpers/reya_tester/positions.py index 3eba6140..6b7ed08e 100644 --- a/tests/helpers/reya_tester/positions.py +++ b/tests/helpers/reya_tester/positions.py @@ -5,6 +5,7 @@ import asyncio import logging import time +from decimal import Decimal from sdk.open_api.exceptions import ApiException from sdk.open_api.models.side import Side @@ -26,6 +27,15 @@ class PositionOperations: def __init__(self, tester: "ReyaTester"): self._t = tester + async def signed_qty(self, symbol: str) -> Decimal: + """Signed position size on `symbol`: + long, − short, 0 when flat.""" + position = await self._t.data.position(symbol) + if position is None or position.qty is None: + return Decimal("0") + qty = Decimal(str(position.qty)) + # Sides on the API: 'B' = Bid/long (positive), 'A' = Ask/short (negative). + return qty if str(position.side) in ("Side.B", "B") else -qty + async def close_all(self, fail_if_none: bool = True) -> None: """Best-effort: try to flatten any open positions. diff --git a/tests/helpers/reya_tester/tester.py b/tests/helpers/reya_tester/tester.py index 6c9af890..36ae4556 100644 --- a/tests/helpers/reya_tester/tester.py +++ b/tests/helpers/reya_tester/tester.py @@ -8,6 +8,7 @@ from dotenv import load_dotenv +from sdk.open_api.models.cancel_all_after_response import CancelAllAfterResponse from sdk.reya_rest_api import ReyaTradingClient from sdk.reya_rest_api.config import TradingConfig from sdk.reya_websocket import ReyaSocket @@ -244,6 +245,22 @@ def perp_trade(self) -> PerpTradeContext: """ return PerpTradeContext(tester=self) + async def arm_cod(self, timeout_ms: int) -> CancelAllAfterResponse: + """Arm or refresh the account-wide cancel-all-after countdown + (cancel-on-disconnect dead-man's-switch). + + Thin wrapper over ``client.cancel_all_after``; ``timeout_ms`` must be + within [5000, 60000] ms. Each call replaces the running countdown. + """ + return await self.client.cancel_all_after(timeout_ms=timeout_ms) + + async def disarm_cod(self) -> CancelAllAfterResponse: + """Disarm the cancel-all-after countdown (``timeoutMs=0``). + + Idempotent server-side: disarming an unarmed account is a no-op. + """ + return await self.client.cancel_all_after(timeout_ms=0) + def get_next_nonce(self) -> int: """ Get the next nonce from the SDK's nonce tracking mechanism. diff --git a/tests/helpers/settlement.py b/tests/helpers/settlement.py new file mode 100644 index 00000000..d2ae8892 --- /dev/null +++ b/tests/helpers/settlement.py @@ -0,0 +1,148 @@ +"""Market-agnostic settlement probes for [spot, perp]-parametrized fill tests. + +A crossing/filling test asserts the same engine behavior on both markets +(FILLED, execQty, both orderIds, no busts) — only the SETTLEMENT proof +diverges: a spot fill moves asset balances, a perp fill moves a signed +position. These probes encapsulate exactly that divergence behind one +interface (`capture_baseline` / `assert_settled`), so a fill test can be +written ONCE and parametrized, with the right probe injected by market type. + +Each probe preserves the FULL strength of the hand-written twin it replaces: +- SpotSettlementProbe → `verify_spot_trade_balance_changes` (exact zero-fee + base + quote deltas on BOTH accounts), after polling the buyer's base + balance to its expected value to absorb indexer lag. +- PerpSettlementProbe → two `check.position_delta` assertions (both accounts' + signed positions moved by exactly ±qty from their captured baselines). + +Convention: `buyer` is the aggressor (the account whose order crosses / +takes), `seller` is the resting counterparty. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +import asyncio +import time +from dataclasses import dataclass, field +from decimal import Decimal + +from sdk.reya_rest_api.config import REYA_DEX_ID +from tests.helpers.market_config import MarketTestConfig + +if TYPE_CHECKING: + from tests.helpers import ReyaTester + +QUOTE_ASSET = "RUSD" +SETTLEMENT_TIMEOUT_S = 10.0 + + +class SettlementProbe(Protocol): + """Captures pre-fill settlement state and asserts the post-fill delta.""" + + async def capture_baseline(self) -> None: ... # noqa: E704 + + async def assert_settled( # noqa: E704 + self, qty: str, price: str, timeout_s: float = SETTLEMENT_TIMEOUT_S + ) -> None: ... + + +@dataclass +class SpotSettlementProbe: + """Asserts a spot fill moved both accounts' asset balances by the EXACT + zero-fee amounts (base ±qty, quote ∓qty·price).""" + + market_config: MarketTestConfig + buyer: ReyaTester + seller: ReyaTester + _buyer_initial: dict | None = field(default=None, repr=False) + _seller_initial: dict | None = field(default=None, repr=False) + + async def capture_baseline(self) -> None: + self._buyer_initial = await self.buyer.data.balances() + self._seller_initial = await self.seller.data.balances() + + async def assert_settled(self, qty: str, price: str, timeout_s: float = SETTLEMENT_TIMEOUT_S) -> None: + assert self._buyer_initial is not None and self._seller_initial is not None, "capture_baseline() not called" + base_asset = self.market_config.base_asset + + # Poll the buyer's base balance to its expected post-fill value first + # (the position/balance view trails on-chain settlement by the indexer + # write lag), then assert the EXACT deltas on both accounts. + buyer_initial_base = self._buyer_initial.get(base_asset) + assert buyer_initial_base is not None, f"Buyer has no {base_asset} balance to baseline against" + expected_buyer_base = Decimal(buyer_initial_base.real_balance) + Decimal(qty) + deadline = time.time() + timeout_s + buyer_final = await self.buyer.data.balances() + while time.time() < deadline: + buyer_final_base = buyer_final.get(base_asset) + if buyer_final_base is not None and Decimal(buyer_final_base.real_balance) == expected_buyer_base: + break + await asyncio.sleep(0.2) + buyer_final = await self.buyer.data.balances() + seller_final = await self.seller.data.balances() + + # The aggressor (buyer) is the trade-TAKER; the resting counterparty + # (seller) is the trade-MAKER and is NOT the buyer → is_maker_buyer=False. + self.buyer.ws.verify_spot_trade_balance_changes( + maker_initial_balances=self._seller_initial, + maker_final_balances=seller_final, + taker_initial_balances=self._buyer_initial, + taker_final_balances=buyer_final, + base_asset=base_asset, + quote_asset=QUOTE_ASSET, + qty=qty, + price=price, + is_maker_buyer=False, + ) + + +@dataclass +class PerpSettlementProbe: + """Asserts a perp fill moved both accounts' signed positions by exactly + ±qty from their captured baselines (`price` unused — perp settles to + positions, not balances).""" + + market_config: MarketTestConfig + buyer: ReyaTester + seller: ReyaTester + _buyer_baseline: Decimal | None = field(default=None, repr=False) + _seller_baseline: Decimal | None = field(default=None, repr=False) + + async def capture_baseline(self) -> None: + symbol = self.market_config.symbol + self._buyer_baseline = await self.buyer.positions.signed_qty(symbol) + self._seller_baseline = await self.seller.positions.signed_qty(symbol) + + async def assert_settled( # pylint: disable=unused-argument + self, qty: str, price: str, timeout_s: float = SETTLEMENT_TIMEOUT_S + ) -> None: + # `price` is part of the uniform SettlementProbe interface but unused + # here — perp settles to a signed position, not a notional balance. + assert self._buyer_baseline is not None and self._seller_baseline is not None, "capture_baseline() not called" + symbol = self.market_config.symbol + await self.buyer.check.position_delta( + symbol=symbol, + baseline=self._buyer_baseline, + expected_delta=Decimal(qty), + expected_account_id=self.buyer.account_id, + expected_exchange_id=REYA_DEX_ID, + timeout=timeout_s, + ) + await self.seller.check.position_delta( + symbol=symbol, + baseline=self._seller_baseline, + expected_delta=-Decimal(qty), + expected_account_id=self.seller.account_id, + expected_exchange_id=REYA_DEX_ID, + timeout=timeout_s, + ) + + +def make_settlement_probe( + market_type: str, market_config: MarketTestConfig, buyer: ReyaTester, seller: ReyaTester +) -> SettlementProbe: + """Build the settlement probe matching the active market parametrization.""" + if market_type == "spot": + return SpotSettlementProbe(market_config=market_config, buyer=buyer, seller=seller) + return PerpSettlementProbe(market_config=market_config, buyer=buyer, seller=seller) diff --git a/tests/helpers/ws_exec_harness.py b/tests/helpers/ws_exec_harness.py new file mode 100644 index 00000000..03516a3f --- /dev/null +++ b/tests/helpers/ws_exec_harness.py @@ -0,0 +1,81 @@ +"""Raw ws-exec WebSocket harness shared by the transport-level e2e suites. + +The high-level :class:`ReyaWsExecClient` rejects malformed payloads locally +(and never builds tampered signatures), so negative probes of the SERVER's +validation must go over a short-lived raw WebSocket with a hand-built, +correctly-signed envelope. These helpers were promoted from +tests/ws_exec/test_ws_exec.py once the cod / modify / post-only ws-exec +variants needed them too. +""" + +from __future__ import annotations + +import json +import ssl +import time + +from websocket import WebSocket, create_connection # type: ignore[attr-defined] # pylint: disable=no-name-in-module + +RECV_TIMEOUT_S = 15.0 + + +def raw_connect(url: str) -> WebSocket: + sslopt = {"cert_reqs": ssl.CERT_REQUIRED} + return create_connection(url, sslopt=sslopt) + + +def raw_send_envelope(ws: WebSocket, msg_type: str, env_id: str, payload: dict) -> None: + # Drop None-valued fields so a raw frame matches what the high-level OpenAPI + # client puts on the wire (it serializes with exclude_none). Notably the + # ws-exec server rejects a *present* null `reduceOnly` on spot with + # INPUT_VALIDATION ("reduceOnly field is not supported for spot markets"). + clean = {k: v for k, v in payload.items() if v is not None} + ws.send(json.dumps({"type": msg_type, "id": env_id, "payload": clean})) + + +def raw_recv_until( + ws: WebSocket, + predicate, + timeout_s: float = RECV_TIMEOUT_S, +) -> dict: + """Read frames until ``predicate(frame)`` returns True. Times out cleanly.""" + deadline = time.time() + timeout_s + while time.time() < deadline: + ws.settimeout(max(0.1, deadline - time.time())) + try: + raw = ws.recv() + except Exception as exc: # noqa: BLE001 — surface as RuntimeError below + raise RuntimeError(f"raw recv failed: {exc}") from exc + if not raw: + continue + frame: dict = json.loads(raw) + if frame.get("type") == "ping": + pong: dict = {"type": "pong"} + if frame.get("id") is not None: + pong["id"] = frame["id"] + ws.send(json.dumps(pong)) + continue + if predicate(frame): + return frame + raise RuntimeError("raw recv timed out before predicate matched") + + +def assert_top_level_error(frame: dict, expected_code: str, op_label: str) -> None: + if frame.get("type") != "error": + raise RuntimeError(f"[{op_label}] expected type=error, got {frame!r}") + err = frame.get("error", {}) or {} + actual = err.get("error") + if actual != expected_code: + raise RuntimeError(f"[{op_label}] expected {expected_code!r}, got {actual!r} message={err.get('message')!r}") + + +def assert_per_op_error(frame: dict, expected_codes: tuple[str, ...], op_label: str) -> dict: + if frame.get("ok"): + raise RuntimeError(f"[{op_label}] expected error, got ok=true payload={frame.get('payload')!r}") + err = frame.get("error", {}) or {} + actual = err.get("error") + if actual not in expected_codes: + raise RuntimeError( + f"[{op_label}] expected one of {expected_codes!r}, got error={actual!r} message={err.get('message')!r}" + ) + return err diff --git a/tests/parity/sign_ts.mjs b/tests/parity/sign_ts.mjs index 875e184d..09bd7bd2 100644 --- a/tests/parity/sign_ts.mjs +++ b/tests/parity/sign_ts.mjs @@ -108,6 +108,34 @@ const orderPostOnlyValue = { }, }; +// GTT *create* vector: identical to orderValue except timeInForce=2 (GTT) and a +// non-zero expiresAfter strictly after the deadline (the order has to outlive +// its own entry window). Pins the GTT create path's signed timeInForce==2 + +// non-zero expiresAfter encoding — the base/sell/post_only vectors are all IOC +// (timeInForce=1, expiresAfter=0), so a silent GTT→0/1 mismap would slip past +// them while the wire string stayed "GTT". The Python side reproduces this both +// via direct sign_order(int(TimeInForceInt.GTT)) and via +// build_create_limit_order_payload. +const orderGttCreateValue = { + ...orderValue, + order: { + ...orderValue.order, + timeInForce: 2, // GTT (rests + auto-expires at expiresAfter) + expiresAfter: BigInt(1745000600), // strictly after deadline 1745000000 + }, +}; + +// GTT create + postOnly=true: same as the GTT create vector with the maker-only +// flag set, pinning that a GTT can also be post-only end-to-end (timeInForce==2 +// AND postOnly==true both signed). +const orderGttPostOnlyValue = { + ...orderGttCreateValue, + order: { + ...orderGttCreateValue.order, + postOnly: true, + }, +}; + // === OrderCancel (matching-engine layer) === const orderCancelTypes = { OrderCancel: [ @@ -160,6 +188,62 @@ const massCancelValue = { }, }; +// === CancelAllAfter (matching-engine layer, cancel-on-disconnect) === +const cancelAllAfterTypes = { + CancelAllAfter: [ + { name: "verifyingChainId", type: "uint64" }, + { name: "deadline", type: "uint64" }, + { name: "cancelAllAfter", type: "CancelAllAfterDetails" }, + ], + CancelAllAfterDetails: [ + { name: "accountId", type: "uint64" }, + { name: "timeoutMs", type: "uint64" }, + { name: "nonce", type: "uint64" }, + ], +}; + +const cancelAllAfterArmValue = { + verifyingChainId: BigInt(CHAIN_ID), + deadline: BigInt(1745000180), + cancelAllAfter: { + accountId: 12345n, + timeoutMs: 30000n, + nonce: BigInt(1700000000000003), + }, +}; + +// timeoutMs 0 = disarm; pins the zero path distinctly from the arm vector. +const cancelAllAfterDisarmValue = { + verifyingChainId: BigInt(CHAIN_ID), + deadline: BigInt(1745000240), + cancelAllAfter: { + accountId: 12345n, + timeoutMs: 0n, + nonce: BigInt(1700000000000004), + }, +}; + +// Modify signs the SAME Order envelope over the full post-modify state — no +// dedicated typed-data schema. This vector is orderValue with all four +// modifiable fields changed (px/qty/postOnly/expiresAfter) on a resting GTT +// (the modifiable order that legitimately carries a non-zero expiresAfter, +// strictly after the deadline), and a fresh nonce; the Python side must +// reproduce it through the modify payload builder, proving modify == order +// signing over the post-modify struct. +const orderModifyStateValue = { + verifyingChainId: BigInt(CHAIN_ID), + deadline: BigInt(1745000300), + order: { + ...orderValue.order, + quantity: BigInt("750000000000000000"), // +0.75 E18 (new TOTAL qty) + limitPrice: BigInt("2950000000000000000000"), // 2950 E18 (new px) + timeInForce: 2, // GTT (resting + auto-expires at expiresAfter) + postOnly: true, + expiresAfter: BigInt(1745003600), + nonce: BigInt(1700000000000005), + }, +}; + const wallet = new Wallet(PRIVATE_KEY); const orderSig = await wallet.signTypedData(domain, orderTypes, orderValue); @@ -173,6 +257,16 @@ const orderPostOnlySig = await wallet.signTypedData( orderTypes, orderPostOnlyValue, ); +const orderGttCreateSig = await wallet.signTypedData( + domain, + orderTypes, + orderGttCreateValue, +); +const orderGttPostOnlySig = await wallet.signTypedData( + domain, + orderTypes, + orderGttPostOnlyValue, +); const cancelSig = await wallet.signTypedData( domain, orderCancelTypes, @@ -183,6 +277,21 @@ const massCancelSig = await wallet.signTypedData( massCancelTypes, massCancelValue, ); +const cancelAllAfterArmSig = await wallet.signTypedData( + domain, + cancelAllAfterTypes, + cancelAllAfterArmValue, +); +const cancelAllAfterDisarmSig = await wallet.signTypedData( + domain, + cancelAllAfterTypes, + cancelAllAfterDisarmValue, +); +const orderModifyStateSig = await wallet.signTypedData( + domain, + orderTypes, + orderModifyStateValue, +); console.log( JSON.stringify( @@ -194,8 +303,13 @@ console.log( order: orderSig, order_sell: orderSellSig, order_post_only: orderPostOnlySig, + order_gtt_create: orderGttCreateSig, + order_gtt_post_only: orderGttPostOnlySig, order_cancel: cancelSig, mass_cancel: massCancelSig, + cancel_all_after_arm: cancelAllAfterArmSig, + cancel_all_after_disarm: cancelAllAfterDisarmSig, + order_modify_state: orderModifyStateSig, }, }, null, diff --git a/tests/parity/test_order_details_golden.py b/tests/parity/test_order_details_golden.py index 985e6f21..a6198d0d 100644 --- a/tests/parity/test_order_details_golden.py +++ b/tests/parity/test_order_details_golden.py @@ -11,11 +11,14 @@ inserted after ``reduceOnly``). """ +import pytest from eth_abi import encode from eth_utils import keccak from sdk.reya_rest_api.auth.signatures import _ORDER_DETAILS_TYPE +pytestmark = pytest.mark.offline + # Canonical on-chain OrderDetails struct hash for the reference order below. GOLDEN_DIGEST = "0xafd76928ba06e123f0d14a403d91fdc8a4f653c55bae7282db60b5f0acdde258" diff --git a/tests/parity/test_signature_parity.py b/tests/parity/test_signature_parity.py index 5e3e93c6..d5b41d13 100644 --- a/tests/parity/test_signature_parity.py +++ b/tests/parity/test_signature_parity.py @@ -22,8 +22,13 @@ import pytest +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api import ReyaTradingClient from sdk.reya_rest_api.auth.signatures import OrderTypeInt, SignatureGenerator, TimeInForceInt from sdk.reya_rest_api.config import TradingConfig +from sdk.reya_rest_api.models.orders import LimitOrderParameters, ModifyOrderParameters + +pytestmark = pytest.mark.offline # === Fixed test vector === PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" @@ -53,6 +58,23 @@ "1cd0cc3b3bd3bbee542c7a95f2bc1ebd85afb25152f4b648f57989063745af9c" "1c" ), + # GTT *create*: same envelope as "order" but timeInForce=2 (GTT) and a + # non-zero expiresAfter (1745000600) strictly after the deadline. Pins that + # the SIGNED timeInForce is 2 and a non-zero expiresAfter is covered — a + # silent GTT→0/1 mismap (with the wire string still "GTT") would slip past + # every IOC-based vector. Only timeInForce + expiresAfter differ from "order". + "order_gtt_create": ( + "0xe8df8d60e3f7381f3d23df513addfdf886cbac6e71c727e579cb038c2ce62498" + "7515cca3414e88e2d241c152157ce12cb2454d86e5fb379c865fbe42c289e3fd" + "1c" + ), + # GTT create + postOnly=true: the GTT create vector with the maker-only flag + # set, pinning both timeInForce==2 AND postOnly==true signed together. + "order_gtt_post_only": ( + "0xb23de139806533b33906e8c373054ff1a296d9e6d6a166c8c8ac65a991a529da" + "4d9e939674c951be5b58a4059c0875ef02ade964cf4d06b8bc51d4e453a4746f" + "1b" + ), "order_cancel": ( "0x90ddba6ff879dee4773c214c927a470720f42378574281866edce100ea8c59d7" "75fb29e4ab6108a9ea84bfe12fffcdbbd6dfff98ea6ae034bbd87f4c21254f94" @@ -63,6 +85,38 @@ "7c33b0b77ac8c2e495eca0848e56e60711cd5fa60b657a4ba675fd6bd13be920" "1b" ), + # CancelAllAfter (dead-man's-switch) ARM: timeoutMs=30000. + "cancel_all_after_arm": ( + "0x7d8d65dc7949e42fea86da56df36fbfc0fa07bbd8f8f6be8b9f8f5b2f46031bc" + "190cf90ff18671afe86dce8d27dfceff513e9c8b9df45c94c5bfb937b3b49514" + "1c" + ), + # CancelAllAfter DISARM: timeoutMs=0 — pins the zero path distinctly + # from the arm vector. + "cancel_all_after_disarm": ( + "0x8ef177568c9b4077353f261779b284f33f39d95c73a720c332cf07304ee54091" + "5380177af7b95dabfcd8efbe278ab440b82c6623944818d4a16228fce075beb2" + "1b" + ), + # Modify signs the SAME Order envelope over the full post-modify state — + # the "order" vector with all four modifiable fields changed (qty 0.75, + # limitPrice 2950, postOnly true, expiresAfter 1745003600) on a resting GTT + # (the modifiable order that carries a non-zero expiresAfter), and a fresh + # nonce/deadline. Asserted twice below: once via sign_order (envelope + # identity) and once via build_modify_order_payload (the real client path). + "order_modify_state": ( + "0x3d63bcbae53e3d2d23a7930c68d80e98bd55953ce91553094d8dd87d930a8d36" + "2b2fab25d5873619623813c5ba34d1b03f33f57478ebb58f5b122e4792ec6a82" + "1b" + ), +} + +# === cancelAllAfter ARM vector inputs (tamper table flips one at a time) === +CANCEL_ALL_AFTER_ARM_PARAMS: dict[str, int] = { + "account_id": 12345, + "timeout_ms": 30000, + "nonce": 1700000000000003, + "deadline": 1745000180, } @@ -172,6 +226,83 @@ def test_order_post_only_signature_parity(signer: SignatureGenerator) -> None: ), f"post_only-order signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['order_post_only']}" +def test_order_gtt_create_signature_parity(signer: SignatureGenerator) -> None: + """GTT create must sign timeInForce==2 (not the IOC 1 / GTC 0 the wire-string + can't catch) and a non-zero expiresAfter. + + Identical to the "order" vector except ``time_in_force=int(TimeInForceInt.GTT)`` + (== 2) and ``expires_after=1745000600`` (strictly after the deadline). Any + drift here isolates the GTT timeInForce + expiresAfter encoding. Falsifiable: + a silent GTT→0/1 mismap would change these bytes and fail. + """ + sig = signer.sign_order( + account_id=12345, + market_id=1, + exchange_id=2, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal("0.5"), + limit_price=Decimal("3000"), + trigger_price=Decimal("0"), + time_in_force=int(TimeInForceInt.GTT), + client_order_id=42, + reduce_only=False, + expires_after=1745000600, + nonce=1700000000000000, + deadline=1745000000, + ) + assert ( + sig == EXPECTED_SIGNATURES["order_gtt_create"] + ), f"GTT-create signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['order_gtt_create']}" + # Falsifiability: signing the SAME envelope as IOC (the legacy default) must + # NOT match the GTT golden — proving the golden is sensitive to timeInForce. + sig_ioc = signer.sign_order( + account_id=12345, + market_id=1, + exchange_id=2, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal("0.5"), + limit_price=Decimal("3000"), + trigger_price=Decimal("0"), + time_in_force=int(TimeInForceInt.IOC), + client_order_id=42, + reduce_only=False, + expires_after=1745000600, + nonce=1700000000000000, + deadline=1745000000, + ) + assert sig_ioc != EXPECTED_SIGNATURES["order_gtt_create"], "GTT golden is insensitive to timeInForce" + + +def test_order_gtt_post_only_signature_parity(signer: SignatureGenerator) -> None: + """A GTT can also be post-only: timeInForce==2 AND postOnly==true both signed. + + Identical to the GTT-create vector except ``post_only=True``. Any drift here + isolates the (GTT + postOnly) combination's encoding. + """ + sig = signer.sign_order( + account_id=12345, + market_id=1, + exchange_id=2, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal("0.5"), + limit_price=Decimal("3000"), + trigger_price=Decimal("0"), + time_in_force=int(TimeInForceInt.GTT), + client_order_id=42, + reduce_only=False, + expires_after=1745000600, + nonce=1700000000000000, + deadline=1745000000, + post_only=True, + ) + assert ( + sig == EXPECTED_SIGNATURES["order_gtt_post_only"] + ), f"GTT+postOnly signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['order_gtt_post_only']}" + + def test_order_cancel_signature_parity(signer: SignatureGenerator) -> None: """Python sign_cancel_order produces the same bytes as ethers v6 signTypedData.""" sig = signer.sign_cancel_order( @@ -201,3 +332,198 @@ def test_mass_cancel_signature_parity(signer: SignatureGenerator) -> None: assert ( sig == EXPECTED_SIGNATURES["mass_cancel"] ), f"MassCancel signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['mass_cancel']}" + + +@pytest.mark.cod +def test_cancel_all_after_arm_signature_parity(signer: SignatureGenerator) -> None: + """Python sign_cancel_all_after (arm, timeoutMs=30000) produces the same + bytes as ethers v6 signTypedData.""" + sig = signer.sign_cancel_all_after(**CANCEL_ALL_AFTER_ARM_PARAMS) + assert ( + sig == EXPECTED_SIGNATURES["cancel_all_after_arm"] + ), f"CancelAllAfter arm signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['cancel_all_after_arm']}" + + +@pytest.mark.cod +def test_cancel_all_after_disarm_signature_parity(signer: SignatureGenerator) -> None: + """timeoutMs=0 (disarm) must encode identically to ethers v6 — pins the + zero path distinctly from the arm vector.""" + sig = signer.sign_cancel_all_after( + account_id=12345, + timeout_ms=0, + nonce=1700000000000004, + deadline=1745000240, + ) + assert ( + sig == EXPECTED_SIGNATURES["cancel_all_after_disarm"] + ), f"CancelAllAfter disarm signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['cancel_all_after_disarm']}" + + +@pytest.mark.cod +@pytest.mark.parametrize( + "tampered_field, tampered_value", + [ + ("account_id", 12346), + ("timeout_ms", 30001), + ("nonce", 1700000000000004), + ("deadline", 1745000181), + ], +) +def test_cancel_all_after_tamper_changes_signature( + signer: SignatureGenerator, tampered_field: str, tampered_value: int +) -> None: + """Every CancelAllAfterDetails/envelope field is signature-bearing: flipping + any one of accountId / timeoutMs / nonce / deadline must change the bytes. + + Catches a helper that silently drops a field from the typed data (the + signature would still verify for the untampered struct, letting the server + accept a request whose unsigned field was forged in transit).""" + params = {**CANCEL_ALL_AFTER_ARM_PARAMS, tampered_field: tampered_value} + sig = signer.sign_cancel_all_after(**params) + assert sig != EXPECTED_SIGNATURES["cancel_all_after_arm"], ( + f"Tampering {tampered_field} ({CANCEL_ALL_AFTER_ARM_PARAMS[tampered_field]} -> {tampered_value}) " + f"did not change the CancelAllAfter signature — the field is not being signed" + ) + + +@pytest.mark.modify +def test_order_modify_state_signature_parity(signer: SignatureGenerator) -> None: + """Envelope identity: modify has NO dedicated typed-data schema — signing + the full post-modify state through plain ``sign_order`` must reproduce the + TS vector. Same fields as the "order" vector except the four modifiables + (qty 0.75, limitPrice 2950, postOnly true, expiresAfter 1745003600), GTT + TIF (the modifiable order that carries a non-zero expiresAfter), and a + fresh nonce/deadline.""" + sig = signer.sign_order( + account_id=12345, + market_id=1, + exchange_id=2, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal("0.75"), + limit_price=Decimal("2950"), + trigger_price=Decimal("0"), + time_in_force=int(TimeInForceInt.GTT), + client_order_id=42, + reduce_only=False, + expires_after=1745003600, + nonce=1700000000000005, + deadline=1745000300, + post_only=True, + ) + assert ( + sig == EXPECTED_SIGNATURES["order_modify_state"] + ), f"Post-modify Order signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['order_modify_state']}" + + +@pytest.fixture +def offline_client() -> ReyaTradingClient: + """A ReyaTradingClient that can build payloads offline. + + Same seam as tests/parity/test_wire_serialization.py: seed the + symbol→marketId map directly instead of calling ``start()`` (which loads + market definitions over the network), and pin ``dex_id_override=2`` so the + signed exchangeId matches the vector regardless of any REYA_DEX_ID env + override picked up at import time. + """ + config = TradingConfig( + api_url="https://invalid.example", # never called — building is pure + chain_id=CHAIN_ID, + owner_wallet_address=SIGNER_ADDRESS, + private_key=PRIVATE_KEY, + account_id=12345, + dex_id_override=2, + ) + client = ReyaTradingClient(config) + client._symbol_to_market_id = {"ETHRUSDPERP": 1} # pylint: disable=protected-access + client._initialized = True # pylint: disable=protected-access + return client + + +def test_gtt_create_builder_signs_time_in_force_two(offline_client: ReyaTradingClient) -> None: + """Builder-level GTT-create: ``build_create_limit_order_payload`` for a GTT + must produce a signature that matches ``sign_order`` with + ``time_in_force=int(TimeInForceInt.GTT)`` (== 2) — proving the client's + ``_TIME_IN_FORCE_TO_INT[GTT]`` translation feeds the SIGNED int 2, not a + silent 0/1 mismap. The wire string stays "GTT" either way, so only the + signature can catch the mismap. + + The create builder auto-generates the nonce (no override hook), so this is + self-consistency against ``sign_order`` over the SAME nonce/deadline (the + cross-language TS golden for this struct is pinned separately via + ``test_order_gtt_create_signature_parity``).""" + expires_after = 1745000600 + deadline = 1745000000 + payload, nonce = offline_client.build_create_limit_order_payload( + LimitOrderParameters( + symbol="ETHRUSDPERP", + is_buy=True, + limit_px="3000", + qty="0.5", + time_in_force=TimeInForce.GTT, + client_order_id=42, + expires_after=expires_after, + deadline=deadline, + ) + ) + # The wire string is "GTT" regardless of the signed int, so it is NOT what + # proves the mapping. The signature is. + assert payload["timeInForce"] == TimeInForce.GTT.value + assert payload["expiresAfter"] == expires_after + + def _resign(tif_int: int) -> str: + return offline_client.signature_generator.sign_order( + account_id=12345, + market_id=1, + exchange_id=2, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal("0.5"), + limit_price=Decimal("3000"), + trigger_price=Decimal("0"), + time_in_force=tif_int, + client_order_id=42, + reduce_only=False, + expires_after=expires_after, + nonce=nonce, + deadline=deadline, + post_only=False, + ) + + assert payload["signature"] == _resign(int(TimeInForceInt.GTT)), ( + "GTT-create builder did not sign timeInForce==2:\n" + f" payload: {payload['signature']}\n GTT(2): {_resign(int(TimeInForceInt.GTT))}" + ) + # Falsifiability: a silent 0 (GTC) or 1 (IOC) mismap would sign different + # bytes — confirm the assertion above actually discriminates. + assert payload["signature"] != _resign(int(TimeInForceInt.GTC)) + assert payload["signature"] != _resign(int(TimeInForceInt.IOC)) + + +@pytest.mark.modify +def test_modify_order_builder_signature_parity(offline_client: ReyaTradingClient) -> None: + """Builder-level parity: ``build_modify_order_payload`` over matching + inputs must emit the SAME pinned hex as the TS vector — proving modify + == order signing over the post-modify struct through the real client + path (param mapping, TIF translation, resting-clientOrderId resolution), + not just through a hand-fed ``sign_order``.""" + payload, nonce = offline_client.build_modify_order_payload( + ModifyOrderParameters( + symbol="ETHRUSDPERP", + is_buy=True, + limit_px="2950", + qty="0.75", + post_only=True, + expires_after=1745003600, + time_in_force=TimeInForce.GTT, + order_id=63552420354981888, + resting_client_order_id=42, + nonce=1700000000000005, + deadline=1745000300, + ) + ) + assert nonce == 1700000000000005 + assert payload["signature"] == EXPECTED_SIGNATURES["order_modify_state"], ( + f"Modify-builder signature drift:\n py: {payload['signature']}\n" + f" ts: {EXPECTED_SIGNATURES['order_modify_state']}" + ) diff --git a/tests/parity/test_wire_serialization.py b/tests/parity/test_wire_serialization.py index a65ef3c9..2a64023b 100644 --- a/tests/parity/test_wire_serialization.py +++ b/tests/parity/test_wire_serialization.py @@ -5,7 +5,8 @@ symbol→marketId map, and asserts on the emitted wire shape — numeric fields as plain decimal strings (never scientific notation), the decoupled ``deadline`` / ``expiresAfter`` fields, and the order/cancel entry-rule guards -(``reduceOnly``, ``postOnly``, GTT, and the cancel-identifier rules). +(``reduceOnly``, ``postOnly``, the GTC/GTT↔``expiresAfter`` coupling, and the +cancel-identifier rules). Regression: the sell-trigger sentinel limit price is ``Decimal("0.000000001")``, and ``str(Decimal("0.000000001"))`` is ``"1E-9"``. The server's ethers @@ -16,16 +17,26 @@ from __future__ import annotations +from typing import Any + import time import pytest +from sdk.open_api.models.cancel_all_after_request import CancelAllAfterRequest +from sdk.open_api.models.modify_order_request import ModifyOrderRequest from sdk.open_api.models.order_type import OrderType from sdk.open_api.models.time_in_force import TimeInForce from sdk.reya_rest_api import ReyaTradingClient from sdk.reya_rest_api.client import _SPOT_MARKET_ID_OFFSET from sdk.reya_rest_api.config import TradingConfig -from sdk.reya_rest_api.models.orders import LimitOrderParameters, TriggerOrderParameters +from sdk.reya_rest_api.models.orders import ( + LimitOrderParameters, + ModifyOrderParameters, + TriggerOrderParameters, +) + +pytestmark = pytest.mark.offline PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" SIGNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" @@ -141,44 +152,81 @@ def test_limit_payload_post_only_defaults_false(client: ReyaTradingClient) -> No assert payload["postOnly"] is False -def test_post_only_true_rejected_pending_offchain(client: ReyaTradingClient) -> None: - """post_only=True on a GTC limit is rejected until the off-chain 14-field digest - reconstruction lands — no silently un-settleable order. (The OpenAPI/wire field - is already present; the off-chain side is the remaining gate.)""" - with pytest.raises(ValueError, match="post_only=True is not yet supported"): +def test_post_only_true_flows_to_wire(client: ReyaTradingClient) -> None: + """post_only=True on a GTC limit travels end-to-end now that the off-chain + verifies the 14-field digest and the matching engine enforces would-cross: + it is signed and carried on the wire, no longer rejected at entry.""" + payload, _ = client.build_create_limit_order_payload( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=True, + limit_px="3000", + qty="0.01", + time_in_force=TimeInForce.GTC, + post_only=True, + ) + ) + assert payload["postOnly"] is True + + +def test_post_only_with_ioc_rejected(client: ReyaTradingClient) -> None: + """post_only + IOC is self-contradictory (IOC is taker-only; post_only must + rest) and is always rejected, independent of the rollout gate.""" + with pytest.raises(ValueError, match="post_only is not supported on IOC"): client.build_create_limit_order_payload( LimitOrderParameters( symbol=PERP_SYMBOL, is_buy=True, limit_px="3000", qty="0.01", - time_in_force=TimeInForce.GTC, + time_in_force=TimeInForce.IOC, post_only=True, ) ) -def test_post_only_with_ioc_rejected(client: ReyaTradingClient) -> None: - """post_only + IOC is self-contradictory (IOC is taker-only; post_only must - rest) and is always rejected, independent of the rollout gate.""" - with pytest.raises(ValueError, match="post_only is not supported on IOC"): +def test_gtt_accepted_and_signs_expires_after(client: ReyaTradingClient) -> None: + """GTT rests like GTC but auto-expires at ``expiresAfter``: the order signs + and the non-zero ``expiresAfter`` (strictly after the deadline) travels onto + the wire — no longer rejected at entry now that the off-chain digest + ME + rest/reap GTT end-to-end.""" + deadline = int(time.time()) + 60 + expires_after = deadline + 600 + payload, _nonce = client.build_create_limit_order_payload( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=True, + limit_px="3000", + qty="0.01", + time_in_force=TimeInForce.GTT, + deadline=deadline, + expires_after=expires_after, + ) + ) + assert payload["timeInForce"] == TimeInForce.GTT.value + assert payload["expiresAfter"] == expires_after + + +def test_gtt_without_expiry_rejected(client: ReyaTradingClient) -> None: + """GTT requires a non-zero ``expiresAfter`` — a GTT that never expires is a + contradiction (that is GTC). Rejected at entry before signing.""" + with pytest.raises(ValueError, match="GTT orders require a non-zero expires_after"): client.build_create_limit_order_payload( LimitOrderParameters( symbol=PERP_SYMBOL, is_buy=True, limit_px="3000", qty="0.01", - time_in_force=TimeInForce.IOC, - post_only=True, + time_in_force=TimeInForce.GTT, ) ) -def test_gtt_rejected_pending_offchain(client: ReyaTradingClient) -> None: - """GTT is exposed in the OpenAPI enum and signing-capable, but rejected at entry - until the off-chain 14-field digest + GTT expiresAfter validation land — rather - than a KeyError on the GTC/IOC-only TIF map or an un-settleable order.""" - with pytest.raises(ValueError, match="GTT time-in-force is not yet supported"): +def test_gtt_expiry_not_after_deadline_rejected(client: ReyaTradingClient) -> None: + """A GTT whose ``expiresAfter`` is not strictly after the deadline would + expire within (or before) its own entry window — rejected at entry.""" + deadline = int(time.time()) + 600 + with pytest.raises(ValueError, match="GTT expires_after must be greater than deadline"): client.build_create_limit_order_payload( LimitOrderParameters( symbol=PERP_SYMBOL, @@ -186,6 +234,24 @@ def test_gtt_rejected_pending_offchain(client: ReyaTradingClient) -> None: limit_px="3000", qty="0.01", time_in_force=TimeInForce.GTT, + deadline=deadline, + expires_after=deadline, + ) + ) + + +def test_gtc_with_expiry_rejected(client: ReyaTradingClient) -> None: + """GTC never expires — pairing it with a non-zero ``expiresAfter`` is the + legacy GTC-with-expiry shape that is now GTT. Rejected at entry.""" + with pytest.raises(ValueError, match="GTC orders must not expire"): + client.build_create_limit_order_payload( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=True, + limit_px="3000", + qty="0.01", + time_in_force=TimeInForce.GTC, + expires_after=int(time.time()) + 600, ) ) @@ -239,3 +305,154 @@ def test_cancel_accepts_both_identifiers(client: ReyaTradingClient) -> None: ) assert payload["orderId"] == "123" assert payload["clientOrderId"] == 456 + + +def _modify_params(**overrides: Any) -> ModifyOrderParameters: + """A complete, valid post-modify state targeting by order_id. + + The resting order is GTT — the modifiable order that legitimately carries a + non-zero ``expiresAfter`` (strictly after the deadline), satisfying the + GTC/GTT↔``expiresAfter`` coupling. + """ + fields: dict[str, Any] = { + "symbol": PERP_SYMBOL, + "is_buy": True, + "limit_px": "2950", + "qty": "0.75", + "post_only": True, + "expires_after": 1745003600, + "time_in_force": TimeInForce.GTT, + "order_id": 63552420354981888, + "deadline": 1745000300, + "nonce": 1700000000000005, + } + fields.update(overrides) + return ModifyOrderParameters(**fields) + + +@pytest.mark.modify +def test_modify_payload_wire_shape(client: ReyaTradingClient) -> None: + """The modify body always carries ALL FOUR post-modify fields (limitPx, qty, + postOnly, expiresAfter — no omitted-means-inherited shorthand), the + targeting identifier, and signerWallet, with the documented wire types.""" + payload, nonce = client.build_modify_order_payload(_modify_params()) + + assert set(payload.keys()) == { + "orderId", + "clientOrderId", + "symbol", + "accountId", + "exchangeId", + "isBuy", + "orderType", + "timeInForce", + "triggerPx", + "reduceOnly", + "limitPx", + "qty", + "postOnly", + "expiresAfter", + "signature", + "nonce", + "signerWallet", + "deadline", + } + # The four post-modify fields — always present, never None. + assert payload["limitPx"] == "2950" + assert payload["qty"] == "0.75" + assert payload["postOnly"] is True + assert payload["expiresAfter"] == 1745003600 + # Restated immutables (full-restate) — always present. + assert payload["isBuy"] is True + assert payload["orderType"] == "LIMIT" + assert payload["timeInForce"] == "GTT" + assert payload["reduceOnly"] is False + assert payload["triggerPx"] is None # LIMIT carries no trigger + assert isinstance(payload["exchangeId"], int) + # Targeting + auth. + assert payload["orderId"] == "63552420354981888" # orderId is a STRING on the wire + assert isinstance(payload["orderId"], str) + # clientOrderId is the resting order's signed clientOrderId (0 here — the + # GTT fixture targets by orderId and carries no clientOrderId). + assert payload["clientOrderId"] == 0 + assert payload["accountId"] == 12345 + assert payload["signerWallet"] == SIGNER_ADDRESS + assert payload["signature"].startswith("0x") + assert payload["nonce"] == str(nonce) # nonce serializes as a string + assert isinstance(payload["deadline"], int) + + +@pytest.mark.modify +def test_modify_payload_round_trips_generated_model(client: ReyaTradingClient) -> None: + """The payload round-trips through the generated ModifyOrderRequest with + orderId kept as a string (StrictStr per the OpenAPI model) and the four + post-modify fields + targeting + signerWallet surviving serialization.""" + payload, _nonce = client.build_modify_order_payload(_modify_params()) + body = ModifyOrderRequest(**payload).to_dict() + + # to_dict drops None optionals: triggerPx (None for LIMIT) disappears; + # clientOrderId stays (0 is the restated immutable, not None); orderId stays. + assert set(body.keys()) == { + "orderId", + "clientOrderId", + "symbol", + "accountId", + "exchangeId", + "isBuy", + "orderType", + "timeInForce", + "reduceOnly", + "limitPx", + "qty", + "postOnly", + "expiresAfter", + "signature", + "nonce", + "signerWallet", + "deadline", + } + assert body["orderId"] == "63552420354981888" + assert isinstance(body["orderId"], str) + assert isinstance(body["postOnly"], bool) + assert isinstance(body["expiresAfter"], int) + assert isinstance(body["nonce"], str) + + +@pytest.mark.modify +def test_modify_payload_client_order_id_targeting_wire_shape(client: ReyaTradingClient) -> None: + """Targeting by client_order_id: the body carries clientOrderId as an int + and omits orderId.""" + payload, _nonce = client.build_modify_order_payload(_modify_params(order_id=None, client_order_id=777)) + assert payload["orderId"] is None + assert payload["clientOrderId"] == 777 + body = ModifyOrderRequest(**payload).to_dict() + assert "orderId" not in body + assert body["clientOrderId"] == 777 + + +@pytest.mark.cod +def test_cancel_all_after_payload_wire_shape(client: ReyaTradingClient) -> None: + """The cancelAllAfter body is exactly {accountId, timeoutMs, signature, + nonce, signerWallet, deadline} with the documented wire types, and + round-trips through the generated CancelAllAfterRequest.""" + payload = client.build_cancel_all_after_payload(timeout_ms=30000) + + assert set(payload.keys()) == { + "accountId", + "timeoutMs", + "signature", + "nonce", + "signerWallet", + "deadline", + } + assert payload["accountId"] == 12345 + assert payload["timeoutMs"] == 30000 + assert isinstance(payload["timeoutMs"], int) + assert payload["signature"].startswith("0x") + assert isinstance(payload["nonce"], str) # nonce serializes as a string + assert payload["signerWallet"] == SIGNER_ADDRESS + assert isinstance(payload["deadline"], int) + + body = CancelAllAfterRequest(**payload).to_dict() + assert set(body.keys()) == set(payload.keys()) + assert body["timeoutMs"] == 30000 diff --git a/tests/perp/__init__.py b/tests/perp/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_perps/test_limit_orders.py b/tests/perp/test_limit_orders.py similarity index 62% rename from tests/test_perps/test_limit_orders.py rename to tests/perp/test_limit_orders.py index 3c38d76a..7f6c4e09 100644 --- a/tests/test_perps/test_limit_orders.py +++ b/tests/perp/test_limit_orders.py @@ -13,7 +13,7 @@ ``GET /v2/wallet/{address}/openOrders``. The shared place/cancel/match-in-isolation lifecycle for both market types -lives in tests/test_orderbook/; this module covers what's genuinely +lives in tests/engine/; this module covers what's genuinely perp-specific. """ @@ -24,10 +24,7 @@ import pytest from sdk.open_api.exceptions import ApiException -from sdk.open_api.models.order_status import OrderStatus -from sdk.open_api.models.side import Side from sdk.open_api.models.time_in_force import TimeInForce -from sdk.reya_rest_api.config import REYA_DEX_ID from sdk.reya_rest_api.models import LimitOrderParameters from tests.helpers import ReyaTester from tests.helpers.liquidity_detector import skip_if_external_liquidity @@ -47,97 +44,6 @@ def _maker_sell_price(market_price: float) -> str: return str(round(market_price * 0.99, 2)) -@pytest.mark.asyncio -async def test_perp_ioc_taker_buy_matches_maker_sell( - perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester -) -> None: - """Maker rests a GTC sell, taker IOC buys, taker accrues a long position.""" - market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) - - # Skip if an external MM is on the book — the maker's −1% sell would - # cross any bid within the ±5% circuit-breaker band and never rest, so - # the IOC taker would have nothing to match against. Mirrors the spot - # maker/taker e2e test in `tests/test_spot/test_maker_taker_matching.py`. - await skip_if_external_liquidity( - perp_taker_tester.data, - PERP_SYMBOL, - market_price, - reason_prefix="test_perp_ioc_taker_buy_matches_maker_sell", - ) - - # Maker posts a sell order below market — taker IOC will lift it. - maker_order_id = await perp_maker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=False, - limit_px=_maker_sell_price(market_price), - qty=PERP_QTY, - time_in_force=TimeInForce.GTC, - ) - ) - assert maker_order_id is not None, "maker GTC was not accepted" - await perp_maker_tester.wait.for_order_creation(order_id=maker_order_id) - - # Taker IOC buy crosses against the maker. - taker_order_id = await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=True, - limit_px=str(round(market_price * 1.05, 2)), # cross all the way - qty=PERP_QTY, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - ) - assert taker_order_id is not None - - # Taker now holds a long position of size PERP_QTY. - await perp_taker_tester.check.position( - symbol=PERP_SYMBOL, - expected_exchange_id=REYA_DEX_ID, - expected_account_id=perp_taker_tester.account_id, - expected_qty=PERP_QTY, - expected_side=Side.B, - ) - - -@pytest.mark.asyncio -async def test_perp_gtc_rests_on_book(perp_maker_tester: ReyaTester) -> None: - """A GTC perp order placed away from market rests on the book and is queryable.""" - market_price = float(await perp_maker_tester.data.current_price(PERP_SYMBOL)) - safe_resting_price = str(round(market_price * 0.5, 2)) # far below market — won't match - - order_id = await perp_maker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=True, - limit_px=safe_resting_price, - qty=PERP_QTY, - time_in_force=TimeInForce.GTC, - ) - ) - assert order_id is not None - - # The API pod's `OrdersProvider` stream consumer (in - # packages/common-backend/src/providers/redis-stream-reader.ts) calls - # `XREAD BLOCK 5000` against the `{orders}:changes` Redis Stream, and - # measurement showed the BLOCK isn't being woken up by new messages — - # propagation to `/v2/wallet/{address}/openOrders` is clamped at the - # 5,000 ms timeout. The 6 s retry budget below covers that worst case - # with a small margin; once the underlying lag is fixed (off-chain - # repo issue Reya-Labs/reya-off-chain-monorepo#2663) we can drop this - # back to ~500 ms. - open_order = None - for _ in range(60): - open_order = await perp_maker_tester.data.open_order(order_id) - if open_order is not None: - break - await asyncio.sleep(0.1) - assert open_order is not None, "GTC perp order should be visible in open orders" - assert open_order.status == OrderStatus.OPEN - assert open_order.symbol == PERP_SYMBOL - - @pytest.mark.asyncio async def test_perp_reduce_only_rejected_without_position( perp_maker_tester: ReyaTester, @@ -208,7 +114,13 @@ async def test_perp_reduce_only_rejected_without_position( reason_prefix="test_perp_reduce_only_rejected_without_position", ) - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + # Absolute-state test: the on-chain reduce-only-from-zero revert only + # fires when the taker's base is actually zero. With any pre-existing + # short, a reduce-only BUY is legitimately valid (it reduces), so the + # invariant under test can't be exercised — skip rather than fail. + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) + if baseline != 0: + pytest.skip(f"account not flat (baseline {baseline}) — absolute-state test (reduce-only-from-zero)") # Guarantee the IOC has a counterparty so the on-chain check actually # runs — see docstring for rationale. @@ -282,34 +194,3 @@ async def test_perp_reduce_only_rejected_without_position( # investigating separately. await asyncio.sleep(0.5) await perp_taker_tester.check.position_not_open(PERP_SYMBOL) - - -@pytest.mark.asyncio -async def test_perp_gtc_cancel_via_mass_cancel(perp_maker_tester: ReyaTester) -> None: - """Mass-cancel works on perp markets under v2.3.0 (was spot-only pre-perpOB).""" - market_price = float(await perp_maker_tester.data.current_price(PERP_SYMBOL)) - safe_buy_px = str(round(market_price * 0.5, 2)) - - placed_ids = [] - for _ in range(2): - order_id = await perp_maker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=True, - limit_px=safe_buy_px, - qty=PERP_QTY, - time_in_force=TimeInForce.GTC, - ) - ) - assert order_id is not None - placed_ids.append(order_id) - - await perp_maker_tester.client.mass_cancel( - symbol=PERP_SYMBOL, - account_id=perp_maker_tester.account_id, - ) - - # Allow a moment for ME to propagate; then assert all cancelled. - await asyncio.sleep(1.0) - for order_id in placed_ids: - await perp_maker_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED) diff --git a/tests/test_perps/test_market_data.py b/tests/perp/test_market_data.py similarity index 99% rename from tests/test_perps/test_market_data.py rename to tests/perp/test_market_data.py index d243388e..831e87db 100644 --- a/tests/test_perps/test_market_data.py +++ b/tests/perp/test_market_data.py @@ -37,7 +37,7 @@ async def test_market_definition(reya_tester: ReyaTester): @pytest.mark.asyncio async def test_market_definitions(reya_tester: ReyaTester): - market_definitions = await reya_tester.client.reference.get_market_definitions() + market_definitions = await reya_tester.client.reference.get_perp_market_definitions() assert market_definitions is not None assert len(market_definitions) > 0 for market_definition in market_definitions: diff --git a/tests/test_perps/test_position_management.py b/tests/perp/test_position_management.py similarity index 52% rename from tests/test_perps/test_position_management.py rename to tests/perp/test_position_management.py index 5027216e..9565e23d 100644 --- a/tests/test_perps/test_position_management.py +++ b/tests/perp/test_position_management.py @@ -6,27 +6,36 @@ have ``perp_maker_tester`` rest GTC liquidity and ``perp_taker_tester`` cross against it via IOC, then assert position state on the taker. +The tests are **baseline-relative**: the shared devnet accounts may carry +pre-existing positions, so each test reads the taker's signed position first +and asserts the signed delta its own trades caused (via +``check.position_delta``). Teardown (``perp_baseline_restore`` in conftest) +trades the delta back so accounts leave exactly as the test got them. +Choreographies whose reduce-only legs only make sense from a given baseline +sign skip themselves when the baseline doesn't cooperate. + Scenarios covered: -- Open a long via taker IOC against maker sell. -- Open a short via taker IOC against maker buy. -- Increase an existing position with a same-side IOC. -- Close a position fully with an opposite-side reduce-only IOC. +- Open a long via taker IOC against maker sell (+qty delta). +- Open a short via taker IOC against maker buy (−qty delta). +- Increase an existing exposure with a same-side IOC. +- Round-trip a position fully with an opposite-side reduce-only IOC. """ from __future__ import annotations +from decimal import Decimal + import pytest -from sdk.open_api.models.side import Side from sdk.open_api.models.time_in_force import TimeInForce from sdk.reya_rest_api.config import REYA_DEX_ID from sdk.reya_rest_api.models import LimitOrderParameters from tests.helpers import ReyaTester from tests.helpers.liquidity_detector import skip_if_external_liquidity -from tests.helpers.reya_tester import logger PERP_SYMBOL = "ETHRUSDPERP" PERP_QTY = "0.01" +PERP_DELTA = Decimal(PERP_QTY) # All tests in this module rest a maker order at oracle ±1% and then cross it @@ -86,228 +95,170 @@ async def _rest_maker_buy(maker: ReyaTester, market_price: float, qty: str = PER return order_id -@pytest.mark.asyncio -async def test_position_open_long_via_taker_ioc(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester) -> None: - """Taker IOC buy lifts maker sell — taker accumulates a long.""" - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) - market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) - - await _rest_maker_sell(perp_maker_tester, market_price) - - await perp_taker_tester.orders.create_limit( +async def _taker_ioc( + taker: ReyaTester, + market_price: float, + is_buy: bool, + qty: str = PERP_QTY, + reduce_only: bool = False, +) -> None: + """Cross the resting maker with an IOC at oracle ±5%.""" + multiplier = 1.05 if is_buy else 0.95 + await taker.orders.create_limit( LimitOrderParameters( symbol=PERP_SYMBOL, - is_buy=True, - limit_px=str(round(market_price * 1.05, 2)), - qty=PERP_QTY, + is_buy=is_buy, + limit_px=str(round(market_price * multiplier, 2)), + qty=qty, time_in_force=TimeInForce.IOC, - reduce_only=False, + reduce_only=reduce_only, ) ) - await perp_taker_tester.check.position( + +def _skip_unless_baseline_long_or_flat(baseline: Decimal) -> None: + """Guard for choreographies whose reduce-only SELL leg assumes the taker + is net long after the opening buys — impossible from a short baseline.""" + if baseline < 0: + pytest.skip( + f"account not flat (baseline {baseline}) — reduce-only sell leg needs a non-negative taker baseline" + ) + + +def _skip_unless_baseline_short_or_flat(baseline: Decimal) -> None: + """Mirror guard: the reduce-only BUY leg assumes the taker is net short + after the opening sells — impossible from a long baseline.""" + if baseline > 0: + pytest.skip(f"account not flat (baseline {baseline}) — reduce-only buy leg needs a non-positive taker baseline") + + +@pytest.mark.asyncio +async def test_position_open_long_via_taker_ioc(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester) -> None: + """Taker IOC buy lifts maker sell — taker gains +PERP_QTY of exposure.""" + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) + market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) + + await _rest_maker_sell(perp_maker_tester, market_price) + await _taker_ioc(perp_taker_tester, market_price, is_buy=True) + + await perp_taker_tester.check.position_delta( symbol=PERP_SYMBOL, - expected_exchange_id=REYA_DEX_ID, + baseline=baseline, + expected_delta=PERP_DELTA, expected_account_id=perp_taker_tester.account_id, - expected_qty=PERP_QTY, - expected_side=Side.B, + expected_exchange_id=REYA_DEX_ID, ) - logger.info("✅ taker holds a long after lifting maker sell") @pytest.mark.asyncio async def test_position_open_short_via_taker_ioc(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester) -> None: - """Taker IOC sell hits maker buy — taker accumulates a short.""" - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + """Taker IOC sell hits maker buy — taker gains −PERP_QTY of exposure.""" + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) await _rest_maker_buy(perp_maker_tester, market_price) + await _taker_ioc(perp_taker_tester, market_price, is_buy=False) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=False, - limit_px=str(round(market_price * 0.95, 2)), - qty=PERP_QTY, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - ) - - await perp_taker_tester.check.position( + await perp_taker_tester.check.position_delta( symbol=PERP_SYMBOL, - expected_exchange_id=REYA_DEX_ID, + baseline=baseline, + expected_delta=-PERP_DELTA, expected_account_id=perp_taker_tester.account_id, - expected_qty=PERP_QTY, - expected_side=Side.A, + expected_exchange_id=REYA_DEX_ID, ) @pytest.mark.asyncio async def test_position_increase_long(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester) -> None: - """Two same-side taker IOCs against fresh maker liquidity stack into a 2x position.""" - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + """Two same-side taker IOCs against fresh maker liquidity stack to a 2x delta.""" + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) # First leg await _rest_maker_sell(perp_maker_tester, market_price) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=True, - limit_px=str(round(market_price * 1.05, 2)), - qty=PERP_QTY, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=True) # Second leg (more maker liquidity, then more taker IOC) await _rest_maker_sell(perp_maker_tester, market_price) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=True, - limit_px=str(round(market_price * 1.05, 2)), - qty=PERP_QTY, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=True) - expected_total = str(float(PERP_QTY) * 2) - await perp_taker_tester.check.position( + await perp_taker_tester.check.position_delta( symbol=PERP_SYMBOL, - expected_exchange_id=REYA_DEX_ID, + baseline=baseline, + expected_delta=PERP_DELTA * 2, expected_account_id=perp_taker_tester.account_id, - expected_qty=expected_total, - expected_side=Side.B, + expected_exchange_id=REYA_DEX_ID, ) @pytest.mark.asyncio async def test_position_increase_short(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester) -> None: """Mirror of test_position_increase_long: two same-side IOC sells against fresh maker buys.""" - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) await _rest_maker_buy(perp_maker_tester, market_price) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=False, - limit_px=str(round(market_price * 0.95, 2)), - qty=PERP_QTY, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=False) await _rest_maker_buy(perp_maker_tester, market_price) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=False, - limit_px=str(round(market_price * 0.95, 2)), - qty=PERP_QTY, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=False) - expected_total = str(float(PERP_QTY) * 2) - await perp_taker_tester.check.position( + await perp_taker_tester.check.position_delta( symbol=PERP_SYMBOL, - expected_exchange_id=REYA_DEX_ID, + baseline=baseline, + expected_delta=-(PERP_DELTA * 2), expected_account_id=perp_taker_tester.account_id, - expected_qty=expected_total, - expected_side=Side.A, + expected_exchange_id=REYA_DEX_ID, ) @pytest.mark.asyncio async def test_position_partial_close_long(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester) -> None: - """Open a 2x long, then close half via reduce-only IOC sell — half the position remains.""" - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + """Buy 0.02 of exposure, then close half via reduce-only IOC sell — net delta +0.01.""" + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) + _skip_unless_baseline_long_or_flat(baseline) market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) initial_qty = "0.02" close_qty = "0.01" - # Open 0.02 long + # Open +0.02 await _rest_maker_sell(perp_maker_tester, market_price, qty=initial_qty) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=True, - limit_px=str(round(market_price * 1.05, 2)), - qty=initial_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=True, qty=initial_qty) # Partial close 0.01 await _rest_maker_buy(perp_maker_tester, market_price, qty=close_qty) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=False, - limit_px=str(round(market_price * 0.95, 2)), - qty=close_qty, - time_in_force=TimeInForce.IOC, - reduce_only=True, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=False, qty=close_qty, reduce_only=True) - expected_remaining = str(float(initial_qty) - float(close_qty)) - await perp_taker_tester.check.position( + await perp_taker_tester.check.position_delta( symbol=PERP_SYMBOL, - expected_exchange_id=REYA_DEX_ID, + baseline=baseline, + expected_delta=Decimal(initial_qty) - Decimal(close_qty), expected_account_id=perp_taker_tester.account_id, - expected_qty=expected_remaining, - expected_side=Side.B, + expected_exchange_id=REYA_DEX_ID, ) @pytest.mark.asyncio async def test_position_partial_close_short(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester) -> None: - """Mirror of partial_close_long: open 2x short, close half via reduce-only IOC buy.""" - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + """Mirror of partial_close_long: sell 0.02 of exposure, close half via reduce-only IOC buy.""" + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) + _skip_unless_baseline_short_or_flat(baseline) market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) initial_qty = "0.02" close_qty = "0.01" await _rest_maker_buy(perp_maker_tester, market_price, qty=initial_qty) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=False, - limit_px=str(round(market_price * 0.95, 2)), - qty=initial_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=False, qty=initial_qty) await _rest_maker_sell(perp_maker_tester, market_price, qty=close_qty) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=True, - limit_px=str(round(market_price * 1.05, 2)), - qty=close_qty, - time_in_force=TimeInForce.IOC, - reduce_only=True, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=True, qty=close_qty, reduce_only=True) - expected_remaining = str(float(initial_qty) - float(close_qty)) - await perp_taker_tester.check.position( + await perp_taker_tester.check.position_delta( symbol=PERP_SYMBOL, - expected_exchange_id=REYA_DEX_ID, + baseline=baseline, + expected_delta=-(Decimal(initial_qty) - Decimal(close_qty)), expected_account_id=perp_taker_tester.account_id, - expected_qty=expected_remaining, - expected_side=Side.A, + expected_exchange_id=REYA_DEX_ID, ) @@ -315,76 +266,51 @@ async def test_position_partial_close_short(perp_maker_tester: ReyaTester, perp_ async def test_position_decrease_without_reduce_only( perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester ) -> None: - """Counter-trade an existing position with reduce_only=False — position should still net down.""" - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + """Counter-trade fresh exposure with reduce_only=False — exposure still nets down.""" + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) initial_qty = "0.02" counter_qty = "0.01" await _rest_maker_sell(perp_maker_tester, market_price, qty=initial_qty) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=True, - limit_px=str(round(market_price * 1.05, 2)), - qty=initial_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=True, qty=initial_qty) await _rest_maker_buy(perp_maker_tester, market_price, qty=counter_qty) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=False, - limit_px=str(round(market_price * 0.95, 2)), - qty=counter_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, # explicitly NOT reduce-only - ) - ) + # explicitly NOT reduce-only + await _taker_ioc(perp_taker_tester, market_price, is_buy=False, qty=counter_qty, reduce_only=False) - expected_remaining = str(float(initial_qty) - float(counter_qty)) - await perp_taker_tester.check.position( + await perp_taker_tester.check.position_delta( symbol=PERP_SYMBOL, - expected_exchange_id=REYA_DEX_ID, + baseline=baseline, + expected_delta=Decimal(initial_qty) - Decimal(counter_qty), expected_account_id=perp_taker_tester.account_id, - expected_qty=expected_remaining, - expected_side=Side.B, + expected_exchange_id=REYA_DEX_ID, ) @pytest.mark.asyncio async def test_position_close_via_reduce_only_ioc(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester) -> None: - """Open a long, then close it fully with an opposite-side reduce-only IOC.""" - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + """Round-trip: open +PERP_QTY, unwind it fully with a reduce-only IOC — net delta zero.""" + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) + _skip_unless_baseline_long_or_flat(baseline) market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) # Open: maker sell + taker buy await _rest_maker_sell(perp_maker_tester, market_price) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=True, - limit_px=str(round(market_price * 1.05, 2)), - qty=PERP_QTY, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=True) + + await perp_taker_tester.check.position_delta( + symbol=PERP_SYMBOL, + baseline=baseline, + expected_delta=PERP_DELTA, ) # Close: maker buy + taker reduce-only sell await _rest_maker_buy(perp_maker_tester, market_price) - await perp_taker_tester.orders.create_limit( - LimitOrderParameters( - symbol=PERP_SYMBOL, - is_buy=False, - limit_px=str(round(market_price * 0.95, 2)), - qty=PERP_QTY, - time_in_force=TimeInForce.IOC, - reduce_only=True, - ) - ) + await _taker_ioc(perp_taker_tester, market_price, is_buy=False, reduce_only=True) - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + await perp_taker_tester.check.position_delta( + symbol=PERP_SYMBOL, + baseline=baseline, + expected_delta=Decimal("0"), + ) diff --git a/tests/test_perps/test_trigger_orders.py b/tests/perp/test_trigger_orders.py similarity index 100% rename from tests/test_perps/test_trigger_orders.py rename to tests/perp/test_trigger_orders.py diff --git a/tests/test_perps/test_wallet_data.py b/tests/perp/test_wallet_data.py similarity index 69% rename from tests/test_perps/test_wallet_data.py rename to tests/perp/test_wallet_data.py index 44dc01ae..1a762f0f 100644 --- a/tests/test_perps/test_wallet_data.py +++ b/tests/perp/test_wallet_data.py @@ -4,8 +4,18 @@ Tests that need a position to form use ``perp_maker_tester`` + ``perp_taker_tester`` so the IOC has a counterparty under perpOB (no AMM = no fill without a maker). Read-only tests (accounts, balances, configuration) keep using ``reya_tester``. + +Position-forming tests are baseline-relative (see test_position_management.py): +they read the taker's signed position first and assert the +PERP_QTY delta the +fill caused, so a pre-existing position on the shared devnet accounts never +fails them. Genuinely absolute tests (asserting an EMPTY positions map) skip +themselves when the account isn't flat. """ +import asyncio +import time +from decimal import Decimal + import pytest from sdk.open_api.models.side import Side @@ -18,6 +28,7 @@ PERP_SYMBOL = "ETHRUSDPERP" PERP_QTY = "0.01" +PERP_DELTA = Decimal(PERP_QTY) async def _rest_maker_sell(maker: ReyaTester, market_price: float, qty: str = PERP_QTY) -> str: @@ -28,8 +39,8 @@ async def _rest_maker_sell(maker: ReyaTester, market_price: float, qty: str = PE cross an external bid that's any higher than −1% rather than resting, and ``for_order_creation`` would then time out waiting for an OPEN status that will never appear. Mirrors the guard used by the maker/taker tests in - ``tests/test_perps/test_limit_orders.py`` and - ``tests/test_perps/test_position_management.py``. + ``tests/perp/test_limit_orders.py`` and + ``tests/perp/test_position_management.py``. """ await skip_if_external_liquidity( maker.data, @@ -53,13 +64,32 @@ async def _rest_maker_sell(maker: ReyaTester, market_price: float, qty: str = PE return order_id +async def _wait_for_perp_execution_after(tester: ReyaTester, baseline_seq: int, timeout: float = 10.0): + """Poll the wallet's latest perp execution until one newer than ``baseline_seq`` is indexed. + + The wallet-executions REST endpoint trails on-chain settlement by the indexer + write lag, so a single immediate read races it (the position tests avoid this + by polling via ``position_delta``). Returns the latest execution on timeout so + the caller's assertion fails against the stale record for context. + """ + deadline = time.time() + timeout + execution = await tester.get_last_wallet_perp_execution() + while time.time() < deadline: + if execution is not None and (execution.sequence_number or 0) > baseline_seq: + return execution + await asyncio.sleep(0.2) + execution = await tester.get_last_wallet_perp_execution() + return execution + + @pytest.mark.asyncio async def test_get_wallet_positions_empty(reya_tester: ReyaTester): """Test getting positions when no positions exist for the symbol""" symbol = "ETHRUSDPERP" await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) + if await reya_tester.positions.signed_qty(symbol) != 0: + pytest.skip("account not flat — absolute-state test (asserts the positions map omits the symbol)") positions = await reya_tester.data.positions() assert isinstance(positions, dict), "Positions should be a dictionary" @@ -73,9 +103,10 @@ async def test_get_wallet_positions_with_position(perp_maker_tester: ReyaTester, """Position formation is verified via wallet positions endpoint. Maker rests a GTC sell, taker crosses with IOC buy. Asserts the taker - wallet now reports a long ETHRUSDPERP position. + wallet's signed position moved by +PERP_QTY and (when the resulting + position is non-zero) that the positions map carries a coherent record. """ - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) await _rest_maker_sell(perp_maker_tester, market_price) @@ -91,16 +122,23 @@ async def test_get_wallet_positions_with_position(perp_maker_tester: ReyaTester, ) ) + await perp_taker_tester.check.position_delta( + symbol=PERP_SYMBOL, + baseline=baseline, + expected_delta=PERP_DELTA, + expected_account_id=perp_taker_tester.account_id, + expected_exchange_id=REYA_DEX_ID, + ) + positions = await perp_taker_tester.data.positions() assert isinstance(positions, dict), "Positions should be a dictionary" - assert PERP_SYMBOL in positions, f"Should have position for {PERP_SYMBOL}" - - position = positions[PERP_SYMBOL] - assert position.symbol == PERP_SYMBOL, "Position symbol should match" - assert position.account_id == perp_taker_tester.account_id, "Position account ID should match" - assert position.exchange_id == REYA_DEX_ID, "Position exchange ID should match" - assert float(position.qty) == float(PERP_QTY), "Position qty should match" - assert position.side == Side.B, "Position side should be BUY" + if baseline + PERP_DELTA != 0: + assert PERP_SYMBOL in positions, f"Should have position for {PERP_SYMBOL}" + position = positions[PERP_SYMBOL] + assert position.symbol == PERP_SYMBOL, "Position symbol should match" + assert position.account_id == perp_taker_tester.account_id, "Position account ID should match" + assert position.exchange_id == REYA_DEX_ID, "Position exchange ID should match" + assert Decimal(str(position.qty)) == abs(baseline + PERP_DELTA), "Position qty should match" logger.info("✅ Wallet positions (with position) test completed successfully") @@ -110,10 +148,9 @@ async def test_get_wallet_perp_executions(perp_maker_tester: ReyaTester, perp_ta """Latest wallet perp execution reflects the most recent fill. Maker rests a GTC sell, taker crosses with IOC buy. Asserts the taker - wallet's latest execution matches the fill. + wallet's latest execution matches the fill. Execution records are + baseline-independent, so no position precondition is needed. """ - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) - last_execution_before = await perp_taker_tester.get_last_wallet_perp_execution() sequence_before = last_execution_before.sequence_number if last_execution_before else 0 @@ -132,7 +169,9 @@ async def test_get_wallet_perp_executions(perp_maker_tester: ReyaTester, perp_ta ) ) - last_execution_after = await perp_taker_tester.get_last_wallet_perp_execution() + # Poll for the new execution to absorb indexer write-lag (a single immediate + # read races the indexer and intermittently returns the pre-fill record). + last_execution_after = await _wait_for_perp_execution_after(perp_taker_tester, sequence_before) assert last_execution_after is not None, "Should have execution after order" assert last_execution_after.sequence_number > sequence_before, "Sequence number should increase" assert last_execution_after.symbol == PERP_SYMBOL, "Execution symbol should match" @@ -206,16 +245,12 @@ async def test_get_wallet_configuration(reya_tester: ReyaTester): @pytest.mark.asyncio async def test_get_single_position(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester): - """Single-position lookup by symbol returns the freshly-formed position. + """Single-position lookup by symbol reflects the fill as a +PERP_QTY signed move. Maker rests a GTC sell, taker crosses with IOC buy. Asserts the - single-position lookup on the taker reflects the fill. + single-position lookup on the taker reports the post-fill state. """ - await perp_taker_tester.check.position_not_open(PERP_SYMBOL) - - initial = await perp_taker_tester.data.position(PERP_SYMBOL) - assert initial is None, "Should not have position initially" - + baseline = await perp_taker_tester.positions.signed_qty(PERP_SYMBOL) market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) await _rest_maker_sell(perp_maker_tester, market_price) @@ -231,9 +266,21 @@ async def test_get_single_position(perp_maker_tester: ReyaTester, perp_taker_tes ) ) + await perp_taker_tester.check.position_delta( + symbol=PERP_SYMBOL, + baseline=baseline, + expected_delta=PERP_DELTA, + expected_account_id=perp_taker_tester.account_id, + expected_exchange_id=REYA_DEX_ID, + ) + + expected_final = baseline + PERP_DELTA position = await perp_taker_tester.data.position(PERP_SYMBOL) - assert position is not None, "Should have position after order" - assert position.symbol == PERP_SYMBOL, "Position symbol should match" - assert float(position.qty) == float(PERP_QTY), "Position qty should match" + if expected_final == 0: + assert position is None, "Position should be gone when the fill flattens the baseline" + else: + assert position is not None, "Should have position after order" + assert position.symbol == PERP_SYMBOL, "Position symbol should match" + assert Decimal(str(position.qty)) == abs(expected_final), "Position qty should match" logger.info("✅ Get single position test completed successfully") diff --git a/tests/test_spot/__init__.py b/tests/spot/__init__.py similarity index 100% rename from tests/test_spot/__init__.py rename to tests/spot/__init__.py diff --git a/tests/test_spot/conftest.py b/tests/spot/conftest.py similarity index 100% rename from tests/test_spot/conftest.py rename to tests/spot/conftest.py diff --git a/tests/test_spot/test_balance_verification.py b/tests/spot/test_balance_verification.py similarity index 99% rename from tests/test_spot/test_balance_verification.py rename to tests/spot/test_balance_verification.py index 971117ab..758626d9 100644 --- a/tests/test_spot/test_balance_verification.py +++ b/tests/spot/test_balance_verification.py @@ -21,7 +21,7 @@ from sdk.open_api.models import OrderStatus from tests.helpers import ReyaTester from tests.helpers.builders.order_builder import OrderBuilder -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") diff --git a/tests/test_spot/test_error_handling.py b/tests/spot/test_error_handling.py similarity index 99% rename from tests/test_spot/test_error_handling.py rename to tests/spot/test_error_handling.py index 60b030b1..da03f85f 100644 --- a/tests/test_spot/test_error_handling.py +++ b/tests/spot/test_error_handling.py @@ -19,7 +19,7 @@ from sdk.open_api.exceptions import ApiException from tests.helpers import ReyaTester from tests.helpers.builders.order_builder import OrderBuilder -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") diff --git a/tests/spot/test_ioc_orders.py b/tests/spot/test_ioc_orders.py new file mode 100644 index 00000000..1dde9d60 --- /dev/null +++ b/tests/spot/test_ioc_orders.py @@ -0,0 +1,69 @@ +""" +Tests for spot IOC (Immediate-Or-Cancel) orders. + +IOC orders execute immediately against available liquidity and cancel +any unfilled portion. These tests verify IOC behavior for spot markets. + +These tests support both empty and non-empty order books: +- When external liquidity exists, tests use it instead of providing their own +- When no external liquidity exists, tests provide maker liquidity as before +- Execution assertions are flexible to handle order book changes between submission and fill +""" + +import pytest +from eth_abi.exceptions import EncodingError + +from sdk.open_api.exceptions import ApiException +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.market_config import SpotTestConfig +from tests.helpers.reya_tester import logger + + +@pytest.mark.spot +@pytest.mark.ioc +@pytest.mark.asyncio +async def test_spot_ioc_price_qty_validation(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test IOC order rejected for invalid price/qty. + + Flow: + 1. Send IOC order with zero quantity + 2. Verify order is rejected with validation error + 3. Send IOC order with negative price + 4. Verify order is rejected with validation error + """ + logger.info("=" * 80) + logger.info(f"SPOT IOC PRICE/QTY VALIDATION TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Test 1: Zero quantity + zero_qty_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).qty("0").ioc().build() + + logger.info("Sending IOC order with zero quantity...") + try: + order_id = await spot_tester.orders.create_limit(zero_qty_params) + # If we get here without error, the API might accept it but not execute + logger.info(f"Order accepted (may be rejected later): {order_id}") + except ApiException as e: + logger.info(f"✅ Zero quantity order rejected: {type(e).__name__}") + + # Test 2: Negative price (if supported by builder) + try: + negative_price_params = OrderBuilder.from_config(spot_config).buy().price("-100").ioc().build() + + logger.info("Sending IOC order with negative price...") + order_id = await spot_tester.orders.create_limit(negative_price_params) + logger.info(f"Order accepted (may be rejected later): {order_id}") + except ApiException as e: + logger.info(f"✅ Negative price order rejected: {type(e).__name__}") + except EncodingError as e: + # eth_abi raises ValueOutOfBounds (subclass of EncodingError) for negative prices + logger.info(f"✅ Negative price order rejected: {type(e).__name__}") + + # Verify no open orders + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT IOC PRICE/QTY VALIDATION TEST COMPLETED") diff --git a/tests/test_spot/test_market_data.py b/tests/spot/test_market_data.py similarity index 99% rename from tests/test_spot/test_market_data.py rename to tests/spot/test_market_data.py index 5696a846..05336c19 100644 --- a/tests/test_spot/test_market_data.py +++ b/tests/spot/test_market_data.py @@ -17,7 +17,7 @@ from sdk.open_api.models.depth import Depth from tests.helpers import ReyaTester from tests.helpers.builders.order_builder import OrderBuilder -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") diff --git a/tests/test_spot/test_market_spot_executions.py b/tests/spot/test_market_spot_executions.py similarity index 99% rename from tests/test_spot/test_market_spot_executions.py rename to tests/spot/test_market_spot_executions.py index f949b0eb..dac98c32 100644 --- a/tests/test_spot/test_market_spot_executions.py +++ b/tests/spot/test_market_spot_executions.py @@ -25,7 +25,7 @@ from sdk.open_api.models import OrderStatus from tests.helpers import ReyaTester from tests.helpers.builders import OrderBuilder -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") diff --git a/tests/test_spot/test_open_orders_rest.py b/tests/spot/test_open_orders_rest.py similarity index 99% rename from tests/test_spot/test_open_orders_rest.py rename to tests/spot/test_open_orders_rest.py index c6ac7721..db2a3eed 100644 --- a/tests/test_spot/test_open_orders_rest.py +++ b/tests/spot/test_open_orders_rest.py @@ -16,7 +16,7 @@ from sdk.open_api.models.order_status import OrderStatus from tests.helpers import ReyaTester from tests.helpers.builders.order_builder import OrderBuilder -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") diff --git a/tests/test_spot/test_order_book.py b/tests/spot/test_order_book.py similarity index 99% rename from tests/test_spot/test_order_book.py rename to tests/spot/test_order_book.py index 60354ac7..844abb03 100644 --- a/tests/test_spot/test_order_book.py +++ b/tests/spot/test_order_book.py @@ -17,8 +17,8 @@ from sdk.open_api.models.order_status import OrderStatus from tests.helpers import ReyaTester from tests.helpers.builders import OrderBuilder +from tests.helpers.market_config import SpotTestConfig from tests.helpers.reya_tester import logger -from tests.test_spot.spot_config import SpotTestConfig @pytest.mark.spot diff --git a/tests/spot/test_pretrade_checks.py b/tests/spot/test_pretrade_checks.py new file mode 100644 index 00000000..1bf20b13 --- /dev/null +++ b/tests/spot/test_pretrade_checks.py @@ -0,0 +1,136 @@ +""" +Spot pre-trade checks — live e2e. + +The API checks the SPOT asset balance before accepting an IOC (the perp +analogue is a margin check — a different rule, covered by perp suites). +Moved from test_api_validation.py when that file was extracted into +tests/api_contract/: these two tests are spot PHYSICS, not envelope +validation, so they stay under tests/spot/ and its balance guard. +""" + +from decimal import Decimal + +import pytest + +from sdk.open_api.exceptions import ApiException +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.market_config import SpotTestConfig +from tests.helpers.reya_tester import logger + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_ioc_insufficient_balance_buy(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an IOC buy order exceeding RUSD balance is rejected. + + IOC orders have pre-trade balance validation to prevent failed executions. + Gets the actual RUSD balance and tries to exceed it by a small amount. + """ + logger.info("=" * 80) + logger.info("SPOT IOC INSUFFICIENT BALANCE (BUY) TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Get the actual RUSD balance for this account + balances = await spot_tester.client.get_account_balances() + rusd_balance = None + for b in balances: + if b.account_id == spot_tester.account_id and b.asset == "RUSD": + rusd_balance = Decimal(b.real_balance) + break + + if rusd_balance is None or rusd_balance <= 0: + pytest.skip("No RUSD balance available for this test") + assert rusd_balance is not None # narrow after the skip above + + logger.info(f"Current RUSD balance: {rusd_balance}") + + # Calculate qty that would require slightly more RUSD than available + # At spot_config.oracle_price, we need (rusd_balance / price) + small_extra ETH + order_price = Decimal(str(spot_config.oracle_price)) + max_qty_at_price = rusd_balance / order_price + # Request 10% more than we can afford + exceeding_qty = str((max_qty_at_price * Decimal("1.1")).quantize(Decimal("0.01"))) + + order_params = ( + OrderBuilder().symbol(spot_config.symbol).buy().price(str(order_price)).qty(exceeding_qty).ioc().build() + ) + + required_rusd = Decimal(exceeding_qty) * order_price + logger.info(f"Sending IOC buy for {exceeding_qty} ETH @ ${order_price}") + logger.info(f"Required RUSD: {required_rusd}, Available: {rusd_balance}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + pytest.fail(f"Order exceeding balance should have been rejected, got: {order_id}") + except ApiException as e: + error_msg = str(e) + # Expect: error=CREATE_ORDER_OTHER_ERROR message='Insufficient balance: required X, available Y' + assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" + assert "Insufficient balance" in error_msg, f"Expected 'Insufficient balance' message, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT IOC INSUFFICIENT BALANCE (BUY) TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_ioc_insufficient_balance_sell(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an IOC sell order exceeding base asset balance is rejected. + + IOC orders have pre-trade balance validation to prevent failed executions. + Gets the actual base asset balance and tries to exceed it by a small amount. + """ + logger.info("=" * 80) + logger.info("SPOT IOC INSUFFICIENT BALANCE (SELL) TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Get the actual base asset balance for this account + base_asset = spot_config.base_asset + balances = await spot_tester.client.get_account_balances() + asset_balance = None + for b in balances: + if b.account_id == spot_tester.account_id and b.asset == base_asset: + asset_balance = Decimal(b.real_balance) + break + + if asset_balance is None or asset_balance <= 0: + pytest.skip(f"No {base_asset} balance available for this test") + assert asset_balance is not None # narrow after the skip above + + logger.info(f"Current {base_asset} balance: {asset_balance}") + + # Request 10% more than we have, quantized to qty_step_size + qty_step = Decimal(spot_config.qty_step_size) if hasattr(spot_config, "qty_step_size") else Decimal("0.01") + exceeding_qty = str((asset_balance * Decimal("1.1")).quantize(qty_step)) + # Round price to tick size + order_price = str(spot_config.price(1.0)) + + order_params = OrderBuilder().symbol(spot_config.symbol).sell().price(order_price).qty(exceeding_qty).ioc().build() + + logger.info(f"Sending IOC sell for {exceeding_qty} {base_asset} @ ${order_price}") + logger.info(f"Required {base_asset}: {exceeding_qty}, Available: {asset_balance}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + pytest.fail(f"Order exceeding balance should have been rejected, got: {order_id}") + except ApiException as e: + error_msg = str(e) + # Expect: error=CREATE_ORDER_OTHER_ERROR message='Insufficient balance: required X, available Y' + assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" + assert "Insufficient balance" in error_msg, f"Expected 'Insufficient balance' message, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT IOC INSUFFICIENT BALANCE (SELL) TEST COMPLETED") diff --git a/tests/spot/test_reduce_only_validation.py b/tests/spot/test_reduce_only_validation.py new file mode 100644 index 00000000..1f8a4e1f --- /dev/null +++ b/tests/spot/test_reduce_only_validation.py @@ -0,0 +1,102 @@ +""" +Server-side rejection of `reduceOnly` on spot orders — live e2e, raw request. + +Split out of test_api_validation.py (that module sits at pylint's module-size +cap); same raw-request conventions. + +The SDK's local guard is pinned offline (tests/parity), but it raises before +anything reaches the wire — a raw API user can still submit the field, so the +server-side rule needs its own pin. Per the locked PRO-133/PRO-208 model, +reduce-only is perp-IOC-only; on spot an EXPLICIT field (even false) is +rejected with INPUT_VALIDATION_ERROR. +""" + +import time +from decimal import Decimal + +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.create_order_request import CreateOrderRequest +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.time_in_force import TimeInForce +from tests.helpers import ReyaTester +from tests.helpers.market_config import SpotTestConfig +from tests.helpers.reya_tester import logger + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_reduce_only_rejected(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that the SERVER rejects an explicit reduceOnly field on a spot order. + + The request is correctly SIGNED (reduce_only=True is part of the 14-field + digest) so the validator's market-type rule, not signature recovery, is + what rejects. The order is priced far from the touch so a validation + regression cannot produce a fill. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER REDUCE-ONLY REJECTED TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + order_price = spot_config.price(0.96) + deadline = int(time.time()) + 60 + nonce = spot_tester.get_next_nonce() + + signature = spot_tester.client.signature_generator.sign_order( + account_id=spot_tester.account_id, + market_id=spot_config.market_id, + exchange_id=spot_tester.client.config.dex_id, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(spot_config.min_qty), + limit_price=Decimal(str(order_price)), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=True, + expires_after=0, + nonce=nonce, + deadline=deadline, + ) + + order_request = CreateOrderRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + exchangeId=spot_tester.client.config.dex_id, + isBuy=True, + limitPx=str(order_price), + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + deadline=deadline, + expiresAfter=0, + reduceOnly=True, + signature=signature, + nonce=str(nonce), + signerWallet=spot_tester.client.signer_wallet_address, + ) + + logger.info("Sending correctly-signed spot order with reduceOnly=true...") + try: + response = await spot_tester.client.orders.create_order(create_order_request=order_request) + # Defensive cleanup if the validation regressed and the order rested. + if response.order_id is not None: + await spot_tester.client.cancel_order( + order_id=response.order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + pytest.fail(f"reduceOnly on a spot order should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e) + assert "INPUT_VALIDATION_ERROR" in error_msg, f"Expected INPUT_VALIDATION_ERROR, got: {error_msg[:200]}" + assert ( + "reduceOnly field is not supported for spot markets" in error_msg + ), f"Expected the spot reduceOnly message, got: {error_msg[:200]}" + logger.info(f"✅ reduceOnly on spot rejected by the server: {error_msg[:120]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT ORDER REDUCE-ONLY REJECTED TEST COMPLETED") diff --git a/tests/test_spot/test_response_validation.py b/tests/spot/test_response_validation.py similarity index 99% rename from tests/test_spot/test_response_validation.py rename to tests/spot/test_response_validation.py index e2991965..d8fd22ce 100644 --- a/tests/test_spot/test_response_validation.py +++ b/tests/spot/test_response_validation.py @@ -21,8 +21,8 @@ from sdk.open_api.models.spot_execution_list import SpotExecutionList from tests.helpers import ReyaTester from tests.helpers.builders.order_builder import OrderBuilder +from tests.helpers.market_config import SpotTestConfig from tests.helpers.validators import validate_order_fields, validate_spot_execution_fields -from tests.test_spot.spot_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") diff --git a/tests/test_spot/test_state_resilience.py b/tests/spot/test_state_resilience.py similarity index 99% rename from tests/test_spot/test_state_resilience.py rename to tests/spot/test_state_resilience.py index 892af504..701071d7 100644 --- a/tests/test_spot/test_state_resilience.py +++ b/tests/spot/test_state_resilience.py @@ -21,8 +21,8 @@ from sdk.reya_websocket import ReyaSocket from tests.helpers import ReyaTester from tests.helpers.builders import OrderBuilder +from tests.helpers.market_config import SpotTestConfig from tests.helpers.reya_tester.retry import with_retry -from tests.test_spot.spot_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") diff --git a/tests/test_spot/test_wallet_spot_executions.py b/tests/spot/test_wallet_spot_executions.py similarity index 99% rename from tests/test_spot/test_wallet_spot_executions.py rename to tests/spot/test_wallet_spot_executions.py index fe74aa96..c6c454cc 100644 --- a/tests/test_spot/test_wallet_spot_executions.py +++ b/tests/spot/test_wallet_spot_executions.py @@ -17,7 +17,7 @@ from sdk.open_api.models.spot_execution_list import SpotExecutionList from tests.helpers import ReyaTester from tests.helpers.builders.order_builder import OrderBuilder -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") diff --git a/tests/test_spot/test_websocket_events.py b/tests/spot/test_websocket_events.py similarity index 51% rename from tests/test_spot/test_websocket_events.py rename to tests/spot/test_websocket_events.py index 7841961b..924d4755 100644 --- a/tests/test_spot/test_websocket_events.py +++ b/tests/spot/test_websocket_events.py @@ -12,233 +12,18 @@ content matches expectations using centralized assertion helpers. """ -import asyncio import logging import pytest -from sdk.open_api.models import OrderStatus from tests.helpers import ReyaTester from tests.helpers.builders.order_builder import OrderBuilder +from tests.helpers.market_config import SpotTestConfig from tests.helpers.reya_tester import limit_order_params_to_order -from tests.test_spot.spot_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") -@pytest.mark.spot -@pytest.mark.websocket -@pytest.mark.asyncio -async def test_spot_ws_order_changes_on_create(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test WebSocket orderChanges event received on order creation. - - Flow: - 1. Clear order change tracking - 2. Place GTC order - 3. Verify orderChanges event received via WebSocket - 4. Verify event contains correct order data (symbol, side, qty) - """ - logger.info("=" * 80) - logger.info(f"SPOT WS ORDER CHANGES ON CREATE TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Clear WebSocket tracking - spot_tester.ws.order_changes.clear() - - # Place GTC order - order_price = spot_config.price(0.96) - - order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() - - logger.info(f"Placing GTC buy: {spot_config.min_qty} @ ${order_price:.2f}") - order_id = await spot_tester.orders.create_limit(order_params) - await spot_tester.wait.for_order_creation(order_id) - logger.info(f"✅ Order created: {order_id}") - - # Verify order change event using ReyaTester method (wait.for_order_creation already waits for WS) - spot_tester.check.ws_order_change_received( - order_id=order_id, - expected_symbol=spot_config.symbol, - expected_side="B", - expected_qty=spot_config.min_qty, - ) - - # Cleanup - await spot_tester.client.cancel_order( - order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT WS ORDER CHANGES ON CREATE TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.websocket -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_ws_order_changes_on_fill( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Test WebSocket orderChanges event received on order fill. - - Works with external liquidity by using IOC orders that match against - external bids, or falls back to maker-taker matching if no external liquidity. - - Flow: - 1. Place IOC sell order to match external bids (or maker order) - 2. Verify orderChanges event shows FILLED status - """ - logger.info("=" * 80) - logger.info(f"SPOT WS ORDER CHANGES ON FILL TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check current order book state - await spot_config.refresh_order_book(taker_tester.data) - - # Clear WebSocket tracking for taker - taker_tester.ws.order_changes.clear() - - # Determine how to execute a fill based on liquidity - if spot_config.has_usable_ask_liquidity: - # External asks exist - taker buys from external - ask_price = spot_config.best_ask_price - assert ask_price is not None - trade_price = float(ask_price) - logger.info(f"Using external ask liquidity at ${trade_price:.2f}") - - # Place GTC buy order that will match external asks - taker_params = OrderBuilder.from_config(spot_config).buy().price(str(ask_price)).gtc().build() - taker_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker placing GTC buy: {spot_config.min_qty} @ ${trade_price:.2f}") - - # Wait for fill - await taker_tester.wait.for_order_state(taker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Taker order filled by external liquidity") - - # Verify order change event - taker_tester.check.ws_order_change_received( - order_id=taker_order_id, - expected_symbol=spot_config.symbol, - expected_status=OrderStatus.FILLED, - ) - elif spot_config.has_usable_bid_liquidity: - # External bids exist - taker sells to external - bid_price = spot_config.best_bid_price - assert bid_price is not None - trade_price = float(bid_price) - logger.info(f"Using external bid liquidity at ${trade_price:.2f}") - - # Place GTC sell order that will match external bids - taker_params = OrderBuilder.from_config(spot_config).sell().price(str(bid_price)).gtc().build() - taker_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${trade_price:.2f}") - - # Wait for fill - await taker_tester.wait.for_order_state(taker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Taker order filled by external liquidity") - - # Verify order change event - taker_tester.check.ws_order_change_received( - order_id=taker_order_id, - expected_symbol=spot_config.symbol, - expected_status=OrderStatus.FILLED, - ) - else: - # No external liquidity - use maker-taker matching - maker_tester.ws.order_changes.clear() - maker_price = spot_config.price(0.97) - - maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() - logger.info(f"Maker placing GTC buy: {spot_config.min_qty} @ ${maker_price:.2f}") - maker_order_id = await maker_tester.orders.create_limit(maker_params) - await maker_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker order created: {maker_order_id}") - - # Taker fills the order - taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).ioc().build() - logger.info("Taker placing IOC sell to fill maker order...") - await taker_tester.orders.create_limit(taker_params) - - # Wait for fill - await asyncio.sleep(0.05) - await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Maker order filled") - - # Verify order change event - maker_tester.check.ws_order_change_received( - order_id=maker_order_id, - expected_symbol=spot_config.symbol, - expected_status=OrderStatus.FILLED, - ) - - # Verify no open orders - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT WS ORDER CHANGES ON FILL TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.websocket -@pytest.mark.asyncio -async def test_spot_ws_order_changes_on_cancel(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test WebSocket orderChanges event received on order cancel. - - Flow: - 1. Place GTC order - 2. Cancel the order - 3. Verify orderChanges event shows CANCELLED status - """ - logger.info("=" * 80) - logger.info(f"SPOT WS ORDER CHANGES ON CANCEL TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Place GTC order - order_price = spot_config.price(0.96) - - order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() - - logger.info(f"Placing GTC buy: {spot_config.min_qty} @ ${order_price:.2f}") - order_id = await spot_tester.orders.create_limit(order_params) - await spot_tester.wait.for_order_creation(order_id) - logger.info(f"✅ Order created: {order_id}") - - # Clear WebSocket tracking before cancel to capture the cancel event - spot_tester.ws.order_changes.clear() - - # Cancel the order - logger.info("Cancelling order...") - await spot_tester.client.cancel_order( - order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - - # Wait for cancellation - await spot_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED, timeout=5) - logger.info("✅ Order cancelled") - - # Verify order change event using ReyaTester method - spot_tester.check.ws_order_change_received( - order_id=order_id, - expected_symbol=spot_config.symbol, - expected_status=OrderStatus.CANCELLED, - ) - - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT WS ORDER CHANGES ON CANCEL TEST COMPLETED") - - @pytest.mark.spot @pytest.mark.websocket @pytest.mark.maker_taker diff --git a/tests/test_spot/test_websocket_snapshots.py b/tests/spot/test_websocket_snapshots.py similarity index 99% rename from tests/test_spot/test_websocket_snapshots.py rename to tests/spot/test_websocket_snapshots.py index c57f043a..d0afdbb2 100644 --- a/tests/test_spot/test_websocket_snapshots.py +++ b/tests/spot/test_websocket_snapshots.py @@ -21,7 +21,7 @@ from sdk.open_api.models import OrderStatus from tests.helpers import ReyaTester from tests.helpers.builders import OrderBuilder -from tests.test_spot.spot_config import SpotTestConfig +from tests.helpers.market_config import SpotTestConfig logger = logging.getLogger("reya.integration_tests") diff --git a/tests/test_orderbook/conftest.py b/tests/test_orderbook/conftest.py deleted file mode 100644 index dfca9b6a..00000000 --- a/tests/test_orderbook/conftest.py +++ /dev/null @@ -1,221 +0,0 @@ -""" -Pytest fixtures for the shared orderbook lifecycle tests. - -Tests under tests/test_orderbook/ exercise behaviours that are identical for -spot and perp markets in the v2.3.0 unified API: place, cancel, mass-cancel, -maker/taker matching, websocket order/depth events. Each test is parametrized -over the ``market_config`` fixture below, which yields a per-market config -matching the shape of ``tests/test_spot/spot_config.SpotTestConfig`` so existing -helpers (OrderBuilder, ReyaTester) keep working. - -Market-specific tests (spot busts, perp triggers/positions/funding) live in -``tests/test_spot/`` and ``tests/test_perps/`` respectively. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Union - -import os -from dataclasses import dataclass, field -from decimal import Decimal - -import pytest -import pytest_asyncio - -from tests.helpers.liquidity_detector import ( - SAFE_NO_MATCH_BUY_PRICE, - SAFE_NO_MATCH_SELL_PRICE, - LiquidityDetector, - OrderBookState, - log_order_book_state, -) -from tests.test_spot.spot_config import SpotTestConfig - -if TYPE_CHECKING: - from tests.helpers.reya_tester.data import DataOperations - -# Tests in this directory are parametrized over both spot and perp; tests that -# only make sense for one market type can filter via params=["spot"] or -# params=["perp"] on a per-test basis. -_DEFAULT_MARKET_TYPES = ("spot", "perp") - - -def pytest_addoption(parser): - """Add CLI options scoped to orderbook tests.""" - parser.addoption( - "--orderbook-perp-asset", - action="store", - default="ETH", - help="Base asset for perp orderbook tests (e.g. ETH). Symbol becomes RUSDPERP.", - ) - - -@dataclass -class PerpTestConfig: - """Mirrors SpotTestConfig's shape so OrderBuilder + helpers can consume either. - - Implements the same liquidity-detection surface (``refresh_order_book``, - ``has_*_liquidity``, ``best_*_price``, etc.) as SpotTestConfig so the shared - test_orderbook tests can treat the two configs interchangeably. - """ - - symbol: str - market_id: int - min_qty: str - qty_step_size: str - oracle_price: float - base_asset: str - min_balance: float - _order_book: OrderBookState | None = field(default=None, repr=False) - - def price(self, multiplier: float = 1.0) -> float: - return round(self.oracle_price * multiplier, 2) - - def buy_price(self, multiplier: float = 0.99) -> float: - return self.price(multiplier) - - def sell_price(self, multiplier: float = 1.01) -> float: - return self.price(multiplier) - - async def refresh_order_book(self, data_ops: DataOperations) -> OrderBookState: - detector = LiquidityDetector(self.oracle_price) - self._order_book = await detector.get_order_book_state(data_ops, self.symbol) - log_order_book_state(self._order_book) - return self._order_book - - @property - def order_book(self) -> OrderBookState | None: - return self._order_book - - @property - def has_any_external_liquidity(self) -> bool: - return False if self._order_book is None else self._order_book.has_any_liquidity - - @property - def has_usable_bid_liquidity(self) -> bool: - return False if self._order_book is None else self._order_book.has_usable_bid_liquidity - - @property - def has_usable_ask_liquidity(self) -> bool: - return False if self._order_book is None else self._order_book.has_usable_ask_liquidity - - @property - def best_bid_price(self) -> Decimal | None: - if self._order_book is None or not self._order_book.bids.has_liquidity: - return None - return self._order_book.bids.best_price - - @property - def best_ask_price(self) -> Decimal | None: - if self._order_book is None or not self._order_book.asks.has_liquidity: - return None - return self._order_book.asks.best_price - - @property - def circuit_breaker_floor(self) -> Decimal: - return (Decimal(str(self.oracle_price)) * Decimal("0.95")).quantize(Decimal("0.01")) - - @property - def circuit_breaker_ceiling(self) -> Decimal: - return (Decimal(str(self.oracle_price)) * Decimal("1.05")).quantize(Decimal("0.01")) - - def get_usable_bid_price_for_qty(self, qty: str) -> Decimal | None: - if self._order_book is None: - return None - return LiquidityDetector(self.oracle_price).get_usable_bid_price(self._order_book, qty) - - def get_usable_ask_price_for_qty(self, qty: str) -> Decimal | None: - if self._order_book is None: - return None - return LiquidityDetector(self.oracle_price).get_usable_ask_price(self._order_book, qty) - - def get_safe_no_match_buy_price(self) -> Decimal: - return SAFE_NO_MATCH_BUY_PRICE - - def get_safe_no_match_sell_price(self) -> Decimal: - return SAFE_NO_MATCH_SELL_PRICE - - -# Type alias for tests that accept either config — duck-typed via the shared surface above. -MarketConfig = Union[SpotTestConfig, PerpTestConfig] - - -@pytest_asyncio.fixture(loop_scope="session", scope="session") -async def perp_market_config(request, maker_tester_session) -> PerpTestConfig: - """Fetch a perp market config for parametrized orderbook tests. - - Resolves the asset from (in order): the ``--orderbook-perp-asset`` CLI flag, - the ``ORDERBOOK_PERP_ASSET`` env var, then the default ``ETH``. Skips if the - testnet/perpOB deployment hasn't enabled this market on the matching engine - (see ``PERP_OB_MARKET_IDS`` launch gate in - https://github.com/Reya-Labs/reya-off-chain-monorepo/pull/2588). - """ - cli_asset = request.config.getoption("--orderbook-perp-asset", default=None) - asset = (cli_asset or os.environ.get("ORDERBOOK_PERP_ASSET", "ETH")).upper() - symbol = f"{asset}RUSDPERP" - - market_def = None - for definition in await maker_tester_session.client.reference.get_market_definitions(): - if definition.symbol == symbol: - market_def = definition - break - - if market_def is None: - pytest.skip(f"Perp market {symbol} not present in /v2/marketDefinitions") - assert market_def is not None # narrows the Optional after the skip above - - # Fail loud rather than swallow the error with a fake price — a wrong oracle - # price silently invalidates every downstream test (limits, liquidity checks, - # circuit-breaker bands). - oracle_price = float(await maker_tester_session.data.current_price(symbol)) - - return PerpTestConfig( - symbol=symbol, - market_id=market_def.market_id, - min_qty=str(market_def.min_order_qty), - qty_step_size=str(market_def.qty_step_size), - oracle_price=oracle_price, - base_asset=asset, - min_balance=float(Decimal(market_def.min_order_qty) * 50), - ) - - -@pytest.fixture(params=_DEFAULT_MARKET_TYPES) -def market_type(request) -> str: - """Parametrize over [spot, perp] — the param drives ``market_config``.""" - param: str = request.param - return param - - -@pytest.fixture -def market_config( # pylint: disable=redefined-outer-name - market_type: str, spot_config: SpotTestConfig, perp_market_config: PerpTestConfig -) -> MarketConfig: - """Yield the right per-market config for the active parametrization. - - Tests use this fixture as the single source of symbol/min_qty/oracle_price, - regardless of whether the parametrization picked spot or perp. The two - config types share the surface OrderBuilder + liquidity helpers need. - """ - return spot_config if market_type == "spot" else perp_market_config - - -@pytest.fixture -def maker(market_type: str, request): # pylint: disable=redefined-outer-name - """Yield the maker tester for the active parametrization. - - Resolve the tester lazily by market type so that, in an env configured for - only one market, the other market's session fixture (which `pytest.skip`s - on missing credentials) is never set up — otherwise a spot-only run would - silently skip every perp parametrization (green with zero coverage), and - vice versa. - """ - return request.getfixturevalue("perp_maker_tester" if market_type == "perp" else "maker_tester") - - -@pytest.fixture -def taker(market_type: str, request): # pylint: disable=redefined-outer-name - """Yield the taker tester for the active parametrization (resolved lazily; - see :func:`maker` for why).""" - return request.getfixturevalue("perp_taker_tester" if market_type == "perp" else "taker_tester") diff --git a/tests/test_orderbook/test_limit_orders.py b/tests/test_orderbook/test_limit_orders.py deleted file mode 100644 index 1fa07462..00000000 --- a/tests/test_orderbook/test_limit_orders.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -Shared limit-order lifecycle tests parametrized over [spot, perp]. - -Under v2.3.0 both market types route through the same matching engine, so a -single test body verifies the place→fill→position-or-balance flow for both. -Spot-only behaviours (auto-exchange busts) live in tests/test_spot/; perp-only -behaviours (triggers, funding, positions) live in tests/test_perps/. -""" - -from __future__ import annotations - -import pytest - -from sdk.open_api.models.order_status import OrderStatus -from sdk.open_api.models.time_in_force import TimeInForce -from sdk.reya_rest_api.models import LimitOrderParameters -from tests.helpers import ReyaTester -from tests.test_orderbook.conftest import PerpTestConfig -from tests.test_spot.spot_config import SpotTestConfig - - -@pytest.mark.asyncio -async def test_gtc_place_and_cancel( - market_config: SpotTestConfig | PerpTestConfig, - market_type: str, - maker: ReyaTester, -) -> None: - """A GTC limit order placed far from market is reachable via REST + cancellable.""" - safe_buy_px = str(round(market_config.oracle_price * 0.5, 2)) - - params = LimitOrderParameters( - symbol=market_config.symbol, - is_buy=True, - limit_px=safe_buy_px, - qty=market_config.min_qty, - time_in_force=TimeInForce.GTC, - ) - response = await maker.client.create_limit_order(params) - assert response.order_id is not None, f"[{market_type}] no order_id in response" - - open_order = await maker.data.open_order(response.order_id) - assert open_order is not None, f"[{market_type}] order not visible via REST after placement" - assert open_order.status == OrderStatus.OPEN - - cancel_response = await maker.client.cancel_order( - symbol=market_config.symbol, - account_id=maker.account_id, - order_id=response.order_id, - ) - assert cancel_response is not None - - await maker.wait.for_order_state(response.order_id, OrderStatus.CANCELLED) - - -@pytest.mark.asyncio -async def test_mass_cancel_clears_open_orders( - market_config: SpotTestConfig | PerpTestConfig, - market_type: str, - maker: ReyaTester, -) -> None: - """Mass-cancel removes all open orders on a symbol (works on both spot and perp under v2.3.0).""" - safe_buy_px = str(round(market_config.oracle_price * 0.5, 2)) - - placed_ids = [] - for _ in range(2): - params = LimitOrderParameters( - symbol=market_config.symbol, - is_buy=True, - limit_px=safe_buy_px, - qty=market_config.min_qty, - time_in_force=TimeInForce.GTC, - ) - response = await maker.client.create_limit_order(params) - assert response.order_id is not None - placed_ids.append(response.order_id) - - await maker.client.mass_cancel( - symbol=market_config.symbol, - account_id=maker.account_id, - ) - - for order_id in placed_ids: - await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED, timeout=10) - # market_type is consumed by the parametrization; tag log lines so failures are easier to triage. - _ = market_type diff --git a/tests/test_spot/test_gtc_orders.py b/tests/test_spot/test_gtc_orders.py deleted file mode 100644 index f9d2758b..00000000 --- a/tests/test_spot/test_gtc_orders.py +++ /dev/null @@ -1,532 +0,0 @@ -""" -Spot GTC (Good-Till-Cancelled) Order Tests - -Tests for GTC order behavior including: -- Full fill when matching liquidity exists -- Partial fill with remainder on book -- No match - order added to book -- Price-time priority (FIFO) -- Best price first matching -- Client order ID tracking - -These tests support both empty and non-empty order books: -- When external liquidity exists, tests use it instead of providing their own -- When no external liquidity exists, tests provide maker liquidity as before -- Execution assertions are flexible to handle order book changes -""" - -from typing import Optional - -import asyncio -import logging -import random -from decimal import Decimal - -import pytest - -from sdk.open_api.models import OrderStatus -from sdk.open_api.models.depth import Depth -from tests.helpers import ReyaTester -from tests.helpers.builders.order_builder import OrderBuilder -from tests.test_spot.spot_config import SpotTestConfig - -logger = logging.getLogger("reya.integration_tests") - - -@pytest.mark.spot -@pytest.mark.gtc -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_gtc_full_fill(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): - """ - Test GTC order fully filled immediately when matching liquidity exists. - - Supports both empty and non-empty order books: - - If external bid liquidity exists, taker sells into it - - If no external liquidity, maker provides bid liquidity first - - Flow: - 1. Check for external bid liquidity - 2. If needed, maker places GTC buy order - 3. Taker places GTC sell order at crossing price - 4. Verify execution occurred - """ - logger.info("=" * 80) - logger.info(f"SPOT GTC FULL FILL TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check for external liquidity - await spot_config.refresh_order_book(maker_tester.data) - - maker_order_id: Optional[str] = None - fill_price: Decimal - - # Determine liquidity source - check both bid and ask - usable_bid_price = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) - usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) - - if usable_bid_price is not None: - # External bids exist - taker sells into them - fill_price = usable_bid_price - logger.info(f"Using external bid liquidity at ${fill_price:.2f}") - taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).gtc().build() - logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${fill_price:.2f}") - taker_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker order sent: {taker_order_id}") - elif usable_ask_price is not None: - # External asks exist - taker buys from them - fill_price = usable_ask_price - logger.info(f"Using external ask liquidity at ${fill_price:.2f}") - taker_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).gtc().build() - logger.info(f"Taker placing GTC buy: {spot_config.min_qty} @ ${fill_price:.2f}") - taker_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker order sent: {taker_order_id}") - else: - # No external liquidity - maker provides liquidity, taker fills - maker_price = spot_config.price(0.99) - fill_price = Decimal(str(maker_price)) - - maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - logger.info(f"Maker placing GTC buy: {spot_config.min_qty} @ ${fill_price:.2f}") - maker_order_id = await maker_tester.orders.create_limit(maker_params) - await maker_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker order created: {maker_order_id}") - - taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).gtc().build() - logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${fill_price:.2f}") - taker_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker order sent: {taker_order_id}") - - # Wait for matching - await asyncio.sleep(0.1) - - # Verify taker order is filled - await taker_tester.wait.for_order_state(taker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Taker order filled") - - # Verify maker order is filled (if we placed one) - if maker_order_id: - await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Maker order filled") - - # Verify no open orders remain from our accounts - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT GTC FULL FILL TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.gtc -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_gtc_partial_fill_remainder_on_book( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Test GTC order partially fills, remainder added to book. - - This test requires a controlled environment to verify partial fill behavior. - When external liquidity exists, taker orders would match against it first, - making it impossible to verify specific partial fill behavior. - - Flow: - 1. Check for external liquidity - skip if present - 2. Maker places small GTC buy order - 3. Taker places larger GTC sell order at crossing price - 4. Verify maker order is fully filled - 5. Verify taker order is partially filled with remainder on book - 6. Cancel taker's remaining order - """ - logger.info("=" * 80) - logger.info(f"SPOT GTC PARTIAL FILL TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check current order book state - await spot_config.refresh_order_book(maker_tester.data) - - # Skip if external liquidity exists - taker would match against it first - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping partial fill test: external liquidity exists. " - "Taker orders would match against external liquidity first." - ) - - # Maker places small GTC buy order - maker_price = spot_config.price(0.99) - maker_qty = spot_config.min_qty - - maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - - logger.info(f"Maker placing GTC buy: {maker_qty} @ ${maker_price:.2f}") - maker_order_id = await maker_tester.orders.create_limit(maker_params) - await maker_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker order created: {maker_order_id}") - - # Taker places larger GTC sell order at crossing price - taker_qty = "0.002" # Larger than maker (0.001) to test partial fill - - taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.99).qty(taker_qty).gtc().build() - - logger.info(f"Taker placing GTC sell: {taker_qty} @ ${maker_price:.2f}") - taker_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker order sent: {taker_order_id}") - - # Wait for matching - await asyncio.sleep(0.1) - - # Verify maker order is fully filled - await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Maker order fully filled") - - # Verify taker order is partially filled (remainder on book) - open_orders = await taker_tester.client.get_open_orders() - taker_open = [o for o in open_orders if o.order_id == taker_order_id] - - assert len(taker_open) == 1, f"Taker order {taker_order_id} should still be on book with remainder" - logger.info("✅ Taker order partially filled, remainder on book") - - # Cleanup - cancel taker's remaining order - await taker_tester.client.cancel_order( - order_id=taker_order_id, symbol=spot_config.symbol, account_id=taker_tester.account_id - ) - await asyncio.sleep(0.05) - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT GTC PARTIAL FILL TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.gtc -@pytest.mark.asyncio -async def test_spot_gtc_no_match_added_to_book(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test GTC order added to book when no match exists. - - Uses a safe no-match price to ensure the order doesn't match existing liquidity. - - Flow: - 1. Check order book for safe no-match price - 2. Place GTC buy order at that price - 3. Verify order is on book (not filled) - 4. Verify order appears in L2 depth - 5. Cancel order - """ - logger.info("=" * 80) - logger.info(f"SPOT GTC NO MATCH (ADDED TO BOOK) TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Check order book to get safe no-match price - await spot_config.refresh_order_book(spot_tester.data) - - # Get a buy price guaranteed not to match (below all asks) - order_price = spot_config.get_safe_no_match_buy_price() - logger.info(f"Safe no-match buy price: ${order_price:.2f}") - - order_params = OrderBuilder.from_config(spot_config).buy().price(str(order_price)).gtc().build() - - logger.info(f"Placing GTC buy: {spot_config.min_qty} @ ${order_price:.2f}") - order_id = await spot_tester.orders.create_limit(order_params) - await spot_tester.wait.for_order_creation(order_id) - logger.info(f"✅ Order created: {order_id}") - - # Verify order is on book (open orders) - open_orders = await spot_tester.client.get_open_orders() - order_on_book = any(o.order_id == order_id for o in open_orders) - assert order_on_book, f"Order {order_id} should be on book" - logger.info("✅ Order is on book (open orders)") - - # Verify order appears in L2 depth - await asyncio.sleep(0.1) - depth = await spot_tester.data.market_depth(spot_config.symbol) - assert isinstance(depth, Depth), f"Expected Depth type, got {type(depth)}" - bids = depth.bids - - found_in_depth = False - for bid in bids: - price = float(bid.px) - if abs(price - float(order_price)) < 10.0: - found_in_depth = True - logger.info(f"✅ Order found in L2 depth at ${price:.2f}") - break - - assert found_in_depth, f"Order at ${order_price:.2f} not found in L2 depth" - - # Cleanup - await spot_tester.client.cancel_order( - order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT GTC NO MATCH TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.gtc -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_gtc_price_time_priority_fifo( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Test multiple GTC orders at same price filled in FIFO order. - - This test requires a controlled environment to verify FIFO behavior. - When external liquidity exists, taker orders would match against it first, - making it impossible to verify specific FIFO behavior. - - Flow: - 1. Check for external liquidity - skip if present - 2. Maker places first GTC buy order at price X - 3. Maker places second GTC buy order at same price X - 4. Taker places GTC sell order that fills one order - 5. Verify first order is filled (FIFO) - 6. Verify second order remains on book - """ - logger.info("=" * 80) - logger.info(f"SPOT GTC PRICE-TIME PRIORITY (FIFO) TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check current order book state - await spot_config.refresh_order_book(maker_tester.data) - - # Skip if external liquidity exists - taker would match against it first - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping FIFO test: external liquidity exists. " - "Taker orders would match against external liquidity first." - ) - - # Same price for both maker orders - maker_price = spot_config.price(0.99) - - # First maker order - first_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - - logger.info(f"Maker placing FIRST GTC buy: {spot_config.min_qty} @ ${maker_price:.2f}") - first_order_id = await maker_tester.orders.create_limit(first_params) - await maker_tester.wait.for_order_creation(first_order_id) - logger.info(f"✅ First order created: {first_order_id}") - - # Small delay to ensure time priority - await asyncio.sleep(0.05) - - # Second maker order at same price - second_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - - logger.info(f"Maker placing SECOND GTC buy: {spot_config.min_qty} @ ${maker_price:.2f}") - second_order_id = await maker_tester.orders.create_limit(second_params) - await maker_tester.wait.for_order_creation(second_order_id) - logger.info(f"✅ Second order created: {second_order_id}") - - # Taker places sell order that fills exactly one order - taker_price = maker_price - - taker_params = ( - OrderBuilder() - .symbol(spot_config.symbol) - .sell() - .price(str(taker_price)) - .qty(spot_config.min_qty) # Same qty as one maker order - .gtc() - .build() - ) - - logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${taker_price:.2f}") - taker_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker order sent: {taker_order_id}") - - # Wait for matching - await asyncio.sleep(0.05) - - # Verify first order is filled (FIFO) - await maker_tester.wait.for_order_state(first_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ First order filled (FIFO priority)") - - # Verify second order is still on book - open_orders = await maker_tester.client.get_open_orders() - second_still_open = any(o.order_id == second_order_id for o in open_orders) - assert second_still_open, f"Second order {second_order_id} should still be on book" - logger.info("✅ Second order remains on book") - - # Cleanup - await maker_tester.client.cancel_order( - order_id=second_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id - ) - await asyncio.sleep(0.05) - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT GTC FIFO TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.gtc -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_gtc_best_price_first( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Test GTC order matches best prices first. - - This test requires a controlled environment to verify best-price-first behavior. - When external liquidity exists, taker orders would match against it first, - making it impossible to verify specific price priority behavior. - - Flow: - 1. Check for external liquidity - skip if present - 2. Maker places GTC buy order at price X - 3. Maker places GTC buy order at better price Y (Y > X) - 4. Taker places GTC sell order - 5. Verify better price order (Y) is filled first - """ - logger.info("=" * 80) - logger.info(f"SPOT GTC BEST PRICE FIRST TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check current order book state - await spot_config.refresh_order_book(maker_tester.data) - - # Skip if external liquidity exists - taker would match against it first - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping best-price-first test: external liquidity exists. " - "Taker orders would match against external liquidity first." - ) - - # First order at lower price (within oracle deviation) - lower_price = spot_config.price(0.96) - - lower_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() - - logger.info(f"Maker placing GTC buy at LOWER price: {spot_config.min_qty} @ ${lower_price:.2f}") - lower_order_id = await maker_tester.orders.create_limit(lower_params) - await maker_tester.wait.for_order_creation(lower_order_id) - logger.info(f"✅ Lower price order created: {lower_order_id}") - - # Second order at higher (better for seller) price - higher_price = spot_config.price(0.99) - - higher_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - - logger.info(f"Maker placing GTC buy at HIGHER price: {spot_config.min_qty} @ ${higher_price:.2f}") - higher_order_id = await maker_tester.orders.create_limit(higher_params) - await maker_tester.wait.for_order_creation(higher_order_id) - logger.info(f"✅ Higher price order created: {higher_order_id}") - - # Taker places sell order that fills exactly one order - taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.96).gtc().build() - - logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${lower_price:.2f}") - taker_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker order sent: {taker_order_id}") - - # Wait for matching - await asyncio.sleep(0.05) - - # Verify higher price order is filled first (best price for seller) - await maker_tester.wait.for_order_state(higher_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Higher price order filled first (best price priority)") - - # Verify lower price order is still on book - open_orders = await maker_tester.client.get_open_orders() - lower_still_open = any(o.order_id == lower_order_id for o in open_orders) - assert lower_still_open, f"Lower price order {lower_order_id} should still be on book" - logger.info("✅ Lower price order remains on book") - - # Cleanup - await maker_tester.client.cancel_order( - order_id=lower_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id - ) - await asyncio.sleep(0.05) - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT GTC BEST PRICE FIRST TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.gtc -@pytest.mark.asyncio -async def test_spot_gtc_with_client_order_id(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test GTC order with clientOrderId tracked correctly. - - Flow: - 1. Place GTC order with custom clientOrderId - 2. Verify clientOrderId is in response - 3. Verify order can be queried and has correct clientOrderId - 4. Cancel order using client_order_id (not order_id) - """ - logger.info("=" * 80) - logger.info(f"SPOT GTC WITH CLIENT ORDER ID TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Generate unique client order ID (positive integer, fits in uint64) - test_client_order_id = random.randint(1, 2**32 - 1) # nosec B311 - - order_price = spot_config.price(0.96) - - order_params = ( - OrderBuilder() - .symbol(spot_config.symbol) - .buy() - .price(str(order_price)) - .qty(spot_config.min_qty) - .gtc() - .client_order_id(test_client_order_id) - .build() - ) - - logger.info(f"Placing GTC buy with clientOrderId={test_client_order_id}...") - order_id = await spot_tester.orders.create_limit(order_params) - await spot_tester.wait.for_order_creation(order_id) - logger.info(f"✅ Order created: {order_id}") - - # Verify the order has the clientOrderId - open_orders = await spot_tester.client.get_open_orders() - our_order = next((o for o in open_orders if o.order_id == order_id), None) - assert our_order is not None, f"Order {order_id} not found in open orders" - - # Check if clientOrderId is returned in the response - # Note: clientOrderId may be in additional_properties or as a direct attribute - if hasattr(our_order, "client_order_id") and our_order.client_order_id is not None: - logger.info(f"✅ clientOrderId verified: {our_order.client_order_id}") - assert ( - our_order.client_order_id == test_client_order_id - ), f"Expected clientOrderId {test_client_order_id}, got {our_order.client_order_id}" - else: - logger.info("Note: clientOrderId not returned in get_open_orders response") - - # Cancel using both order_id and client_order_id - # The API should prefer order_id when both are provided - logger.info(f"Cancelling order using both order_id={order_id} and clientOrderId={test_client_order_id}...") - await spot_tester.client.cancel_order( - order_id=order_id, - client_order_id=test_client_order_id, - symbol=spot_config.symbol, - account_id=spot_tester.account_id, - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - logger.info("✅ Order cancelled (API prefers order_id when both provided)") - - logger.info("✅ SPOT GTC WITH CLIENT ORDER ID TEST COMPLETED") diff --git a/tests/test_spot/test_ioc_orders.py b/tests/test_spot/test_ioc_orders.py deleted file mode 100644 index b81f2cff..00000000 --- a/tests/test_spot/test_ioc_orders.py +++ /dev/null @@ -1,528 +0,0 @@ -""" -Tests for spot IOC (Immediate-Or-Cancel) orders. - -IOC orders execute immediately against available liquidity and cancel -any unfilled portion. These tests verify IOC behavior for spot markets. - -These tests support both empty and non-empty order books: -- When external liquidity exists, tests use it instead of providing their own -- When no external liquidity exists, tests provide maker liquidity as before -- Execution assertions are flexible to handle order book changes between submission and fill -""" - -from typing import Optional - -import asyncio -import time -from decimal import Decimal - -import pytest -from eth_abi.exceptions import EncodingError - -from sdk.open_api.exceptions import ApiException -from sdk.open_api.models.order_status import OrderStatus -from tests.helpers import ReyaTester -from tests.helpers.builders import OrderBuilder -from tests.helpers.reya_tester import limit_order_params_to_order, logger -from tests.test_spot.spot_config import SpotTestConfig - - -@pytest.mark.spot -@pytest.mark.ioc -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_ioc_full_fill(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): - """ - Test IOC order that fully fills against existing liquidity. - - Supports both empty and non-empty order books: - - If external bid liquidity exists, taker sells into it - - If no external liquidity, maker provides bid liquidity first - - Flow: - 1. Check for external bid liquidity - 2. If needed, maker places GTC buy order on the book - 3. Taker sends IOC sell order that matches - 4. Verify execution occurred - """ - logger.info("=" * 80) - logger.info(f"SPOT IOC FULL FILL TEST: {spot_config.symbol}") - logger.info("=" * 80) - - # Clear any existing orders from our accounts - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check for external liquidity - await spot_config.refresh_order_book(maker_tester.data) - - # Record taker's initial balances for verification - base_asset = spot_config.base_asset - taker_balances_before = await taker_tester.data.balances() - base_balance_before = taker_balances_before.get(base_asset) - rusd_balance_before = taker_balances_before.get("RUSD") - taker_base_before = ( - Decimal(str(base_balance_before.real_balance)) if base_balance_before is not None else Decimal("0") - ) - taker_rusd_before = ( - Decimal(str(rusd_balance_before.real_balance)) if rusd_balance_before is not None else Decimal("0") - ) - logger.info(f"Taker initial balances: {base_asset}={taker_base_before}, RUSD={taker_rusd_before}") - - maker_order_id: Optional[str] = None - fill_price: Decimal - - # Step 1: Determine liquidity source - check both bid and ask - usable_bid_price = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) - usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) - - if usable_bid_price is not None: - # External bid liquidity exists - taker sells into it - fill_price = usable_bid_price - logger.info(f"Using external bid liquidity at ${fill_price:.2f}") - taker_order_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() - taker_order_id = await taker_tester.orders.create_limit(taker_order_params) - logger.info(f"Taker IOC sell order sent: {taker_order_id} @ ${fill_price:.2f}") - elif usable_ask_price is not None: - # External ask liquidity exists - taker buys from it - fill_price = usable_ask_price - logger.info(f"Using external ask liquidity at ${fill_price:.2f}") - taker_order_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).ioc().build() - taker_order_id = await taker_tester.orders.create_limit(taker_order_params) - logger.info(f"Taker IOC buy order sent: {taker_order_id} @ ${fill_price:.2f}") - else: - # No external liquidity - provide our own - maker_price = spot_config.price(0.99) - fill_price = Decimal(str(maker_price)) - - maker_order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - maker_order_id = await maker_tester.orders.create_limit(maker_order_params) - await maker_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker GTC buy order created: {maker_order_id} @ ${fill_price:.2f}") - - taker_order_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() - taker_order_id = await taker_tester.orders.create_limit(taker_order_params) - logger.info(f"Taker IOC sell order sent: {taker_order_id} @ ${fill_price:.2f}") - - # Step 3: Wait for execution - expected_taker_order = limit_order_params_to_order(taker_order_params, taker_tester.account_id) - execution = await taker_tester.wait.for_spot_execution(taker_order_id, expected_taker_order) - - # Step 4: Verify execution (flexible - price may differ due to order book changes) - assert execution is not None, "Execution should have occurred" - assert execution.symbol == spot_config.symbol, "Symbol should match" - assert Decimal(execution.qty) <= Decimal(spot_config.min_qty), "Qty should not exceed order qty" - - # Verify fill price is within circuit breaker range - exec_price = Decimal(execution.price) - assert spot_config.circuit_breaker_floor <= exec_price <= spot_config.circuit_breaker_ceiling, ( - f"Fill price ${exec_price} should be within circuit breaker range " - f"[${spot_config.circuit_breaker_floor}, ${spot_config.circuit_breaker_ceiling}]" - ) - logger.info(f"✅ Execution verified: order_id={execution.order_id}, price=${exec_price:.2f}") - - # Verify maker order is filled (if we placed one) - if maker_order_id: - await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Maker order filled") - - # Verify no open orders remain from our accounts - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - # Step 5: Verify taker's balance changed correctly - # Wait for balances to update - await asyncio.sleep(0.5) - taker_balances_after = await taker_tester.data.balances() - base_balance_after = taker_balances_after.get(base_asset) - rusd_balance_after = taker_balances_after.get("RUSD") - taker_base_after = Decimal(str(base_balance_after.real_balance)) if base_balance_after is not None else Decimal("0") - taker_rusd_after = Decimal(str(rusd_balance_after.real_balance)) if rusd_balance_after is not None else Decimal("0") - logger.info(f"Taker final balances: {base_asset}={taker_base_after}, RUSD={taker_rusd_after}") - - # Taker sold base asset, so base asset should decrease and RUSD should increase - taker_base_change = taker_base_after - taker_base_before - taker_rusd_change = taker_rusd_after - taker_rusd_before - logger.info(f"Taker balance changes: {base_asset}={taker_base_change}, RUSD={taker_rusd_change}") - - # Verify base asset decreased (taker sold base asset) - assert taker_base_change < Decimal( - "0" - ), f"Taker {base_asset} should decrease after selling, got change: {taker_base_change}" - # Verify RUSD increased (taker received RUSD) - assert taker_rusd_change > Decimal( - "0" - ), f"Taker RUSD should increase after selling, got change: {taker_rusd_change}" - logger.info(f"✅ Taker balance changes verified ({base_asset} decreased, RUSD increased)") - - # Verify no open orders remain - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT IOC FULL FILL TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.ioc -@pytest.mark.asyncio -async def test_spot_ioc_no_match_cancels(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test IOC order that finds no matching liquidity and cancels. - - Supports both empty and non-empty order books: - - Uses a price guaranteed not to match any existing liquidity - - Price is calculated to be below all asks (for buy) or above all bids (for sell) - - Flow: - 1. Check current order book state - 2. Calculate a safe no-match price - 3. Send IOC order at that price - 4. Verify order is cancelled/rejected (not filled) - - Note: IOC orders without matching liquidity may return a 400 error - or return None for order_id, depending on the API implementation. - """ - logger.info("=" * 80) - logger.info(f"SPOT IOC NO MATCH TEST: {spot_config.symbol}") - logger.info("=" * 80) - - # Clear any existing orders from our account - await spot_tester.orders.close_all(fail_if_none=False) - - # Check current order book to determine safe no-match price - await spot_config.refresh_order_book(spot_tester.data) - - # Clear execution tracking - spot_tester.ws.last_spot_execution = None - start_timestamp = int(time.time() * 1000) - - # Get a buy price guaranteed not to match (below all asks) - safe_buy_price = spot_config.get_safe_no_match_buy_price() - logger.info(f"Safe no-match buy price: ${safe_buy_price:.2f}") - - order_params = OrderBuilder.from_config(spot_config).buy().price(str(safe_buy_price)).ioc().build() - - logger.info(f"Sending IOC buy at ${safe_buy_price:.2f} (expecting no match)...") - - # IOC orders without matching liquidity may raise an error or return None - try: - order_id = await spot_tester.orders.create_limit(order_params) - logger.info(f"IOC order response: {order_id}") - - # If we get here, wait and verify no execution - await asyncio.sleep(0.1) - - last_exec = spot_tester.ws.spot_executions.last - if last_exec is not None and last_exec.timestamp > start_timestamp: - pytest.fail("IOC order should not have executed") - - logger.info("✅ IOC order returned but no execution occurred") - - except ApiException as e: - # IOC orders without liquidity may be rejected with an error - logger.info(f"✅ IOC order rejected as expected: {type(e).__name__}") - - # Verify no open orders (IOC should be cancelled/rejected) - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT IOC NO MATCH TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.ioc -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_ioc_partial_fill(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): - """ - Test IOC order that matches against available liquidity. - - When taker sends a larger IOC order than available quantity, - the IOC fills what it can and the remainder is cancelled. - - Supports both empty and non-empty order books: - - Checks existing bid liquidity and supplements if needed - - Taker sends IOC sell larger than available to test partial fill behavior - - Flow: - 1. Check external bid liquidity - 2. Supplement with maker order if needed to ensure known qty - 3. Taker sends larger IOC order that partially fills - 4. Verify execution occurred - 5. Verify no open orders remain - """ - logger.info("=" * 80) - logger.info(f"SPOT IOC PARTIAL FILL TEST: {spot_config.symbol}") - logger.info("=" * 80) - - # Clear any existing orders for both accounts - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check for external liquidity - await spot_config.refresh_order_book(maker_tester.data) - - maker_order_id: Optional[str] = None - maker_qty = spot_config.min_qty - taker_qty = "0.002" # Larger than maker qty - will partially fill - - # Determine fill price and ensure we have known liquidity - check both bid and ask - usable_bid_price = spot_config.get_usable_bid_price_for_qty(maker_qty) - usable_ask_price = spot_config.get_usable_ask_price_for_qty(maker_qty) - - if usable_bid_price is not None: - # External bid liquidity exists - taker sells into it - fill_price = usable_bid_price - logger.info(f"Using external bid liquidity at ${fill_price:.2f}") - taker_order_params = ( - OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).qty(taker_qty).ioc().build() - ) - logger.info(f"Taker sending IOC sell: {taker_qty} @ ${fill_price:.2f}") - elif usable_ask_price is not None: - # External ask liquidity exists - taker buys from it - fill_price = usable_ask_price - logger.info(f"Using external ask liquidity at ${fill_price:.2f}") - taker_order_params = ( - OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).qty(taker_qty).ioc().build() - ) - logger.info(f"Taker sending IOC buy: {taker_qty} @ ${fill_price:.2f}") - else: - # No external liquidity - provide our own - maker_price = spot_config.price(0.99) - fill_price = Decimal(str(maker_price)) - - maker_order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - maker_order_id = await maker_tester.orders.create_limit(maker_order_params) - await maker_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker order created: {maker_order_id} @ ${fill_price:.2f}") - - taker_order_params = ( - OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).qty(taker_qty).ioc().build() - ) - logger.info(f"Taker sending IOC sell: {taker_qty} @ ${fill_price:.2f}") - taker_tester.ws.last_spot_execution = None - taker_order_id = await taker_tester.orders.create_limit(taker_order_params) - logger.info(f"Taker IOC order sent: {taker_order_id}") - - # Wait for execution - await asyncio.sleep(0.1) - - # Verify maker order is filled (if we placed one) - if maker_order_id: - try: - await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Maker order fully filled - execution confirmed") - except (TimeoutError, RuntimeError): - open_orders = await maker_tester.client.get_open_orders() - maker_still_open = any(o.order_id == maker_order_id for o in open_orders) - if maker_still_open: - raise AssertionError(f"Maker order {maker_order_id} should have been filled but is still open") - logger.info("✅ Maker order no longer open - execution confirmed") - - # Verify no open orders remain from our accounts (IOC remainder was cancelled) - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT IOC PARTIAL FILL TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.ioc -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_ioc_sell_full_fill(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): - """ - Test IOC buy order fully filled against existing sell liquidity. - - Supports both empty and non-empty order books: - - If external ask liquidity exists, taker buys into it - - If no external liquidity, maker provides ask liquidity first - - Flow: - 1. Check for external ask liquidity - 2. If needed, maker places GTC sell order on the book - 3. Taker sends IOC buy order that matches - 4. Verify execution occurred - """ - logger.info("=" * 80) - logger.info(f"SPOT IOC SELL FULL FILL TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check for external liquidity - await spot_config.refresh_order_book(maker_tester.data) - - maker_order_id: Optional[str] = None - fill_price: Decimal - - # Determine liquidity source - usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) - - if usable_ask_price is not None: - # External ask liquidity exists - use it - fill_price = usable_ask_price - logger.info(f"Using external ask liquidity at ${fill_price:.2f}") - else: - # No external liquidity - provide our own - maker_price = spot_config.price(1.01) - fill_price = Decimal(str(maker_price)) - - maker_order_params = OrderBuilder.from_config(spot_config).sell().at_price(1.01).gtc().build() - maker_order_id = await maker_tester.orders.create_limit(maker_order_params) - await maker_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker GTC sell order created: {maker_order_id} @ ${fill_price:.2f}") - - # Taker sends IOC buy order - taker_order_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).ioc().build() - - logger.info(f"Taker sending IOC buy: {spot_config.min_qty} @ ${fill_price:.2f}") - taker_order_id = await taker_tester.orders.create_limit(taker_order_params) - logger.info(f"Taker IOC order sent: {taker_order_id}") - - # Wait for matching - await asyncio.sleep(0.1) - - # Verify maker order is filled (if we placed one) - if maker_order_id: - await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Maker order filled") - - # Verify no open orders remain from our accounts - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT IOC SELL FULL FILL TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.ioc -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_ioc_multiple_price_level_crossing( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Test IOC order that crosses multiple price levels. - - This test requires a controlled environment to verify multi-level matching behavior. - When external liquidity exists, we skip to avoid unpredictable matching. - - Flow: - 1. Check for external liquidity - skip if present - 2. Maker places multiple GTC orders at different prices - 3. Taker sends large IOC order that fills across multiple levels - 4. Verify all maker orders are filled - """ - logger.info("=" * 80) - logger.info(f"SPOT IOC MULTIPLE PRICE LEVEL TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check current order book state - await spot_config.refresh_order_book(maker_tester.data) - - # Skip if external liquidity exists - this test needs controlled environment - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping multi-level crossing test: external liquidity exists. " - "This test requires a controlled environment to verify multi-level matching." - ) - - # Maker places multiple GTC buy orders at different prices within oracle deviation - price_1 = spot_config.price(0.97) # Lower price - price_2 = spot_config.price(0.99) # Higher price (better for seller) - qty_per_order = spot_config.min_qty - - # First order at lower price - order_1_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() - - logger.info(f"Maker placing GTC buy #1: {qty_per_order} @ ${price_1:.2f}") - order_1_id = await maker_tester.orders.create_limit(order_1_params) - await maker_tester.wait.for_order_creation(order_1_id) - logger.info(f"✅ Order #1 created: {order_1_id}") - - # Second order at higher price - order_2_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - - logger.info(f"Maker placing GTC buy #2: {qty_per_order} @ ${price_2:.2f}") - order_2_id = await maker_tester.orders.create_limit(order_2_params) - await maker_tester.wait.for_order_creation(order_2_id) - logger.info(f"✅ Order #2 created: {order_2_id}") - - # Taker sends IOC sell order large enough to fill both our orders - taker_price = price_1 # Same as lower price ensures within oracle deviation - taker_qty = "0.002" # Enough to fill both orders (2 x 0.001) - - taker_order_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).qty(taker_qty).ioc().build() - - logger.info(f"Taker sending IOC sell: {taker_qty} @ ${taker_price:.2f}") - taker_order_id = await taker_tester.orders.create_limit(taker_order_params) - logger.info(f"Taker IOC order sent: {taker_order_id}") - - # Wait for matching - await asyncio.sleep(0.1) - - # Verify both maker orders are filled - await maker_tester.wait.for_order_state(order_1_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Order #1 filled") - - await maker_tester.wait.for_order_state(order_2_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Order #2 filled") - - # Verify no open orders remain from our accounts - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT IOC MULTIPLE PRICE LEVEL TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.ioc -@pytest.mark.asyncio -async def test_spot_ioc_price_qty_validation(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test IOC order rejected for invalid price/qty. - - Flow: - 1. Send IOC order with zero quantity - 2. Verify order is rejected with validation error - 3. Send IOC order with negative price - 4. Verify order is rejected with validation error - """ - logger.info("=" * 80) - logger.info(f"SPOT IOC PRICE/QTY VALIDATION TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Test 1: Zero quantity - zero_qty_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).qty("0").ioc().build() - - logger.info("Sending IOC order with zero quantity...") - try: - order_id = await spot_tester.orders.create_limit(zero_qty_params) - # If we get here without error, the API might accept it but not execute - logger.info(f"Order accepted (may be rejected later): {order_id}") - except ApiException as e: - logger.info(f"✅ Zero quantity order rejected: {type(e).__name__}") - - # Test 2: Negative price (if supported by builder) - try: - negative_price_params = OrderBuilder.from_config(spot_config).buy().price("-100").ioc().build() - - logger.info("Sending IOC order with negative price...") - order_id = await spot_tester.orders.create_limit(negative_price_params) - logger.info(f"Order accepted (may be rejected later): {order_id}") - except ApiException as e: - logger.info(f"✅ Negative price order rejected: {type(e).__name__}") - except EncodingError as e: - # eth_abi raises ValueOutOfBounds (subclass of EncodingError) for negative prices - logger.info(f"✅ Negative price order rejected: {type(e).__name__}") - - # Verify no open orders - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT IOC PRICE/QTY VALIDATION TEST COMPLETED") diff --git a/tests/test_spot/test_maker_taker_matching.py b/tests/test_spot/test_maker_taker_matching.py deleted file mode 100644 index 8da71454..00000000 --- a/tests/test_spot/test_maker_taker_matching.py +++ /dev/null @@ -1,230 +0,0 @@ -""" -End-to-end test for spot maker-taker matching. - -This test uses TWO separate accounts to verify the complete spot trading flow: -- Maker account: Places GTC limit order on the book -- Taker account: Sends IOC order that matches against maker - -Supports both empty and non-empty order books: -- When external liquidity exists, tests can use it -- When no external liquidity exists, tests provide maker liquidity as before -""" - -import asyncio - -import pytest - -from sdk.open_api.models.depth import Depth -from sdk.open_api.models.order_status import OrderStatus -from tests.helpers import ReyaTester -from tests.helpers.builders import OrderBuilder -from tests.helpers.reya_tester import limit_order_params_to_order, logger -from tests.test_spot.spot_config import SpotTestConfig - - -@pytest.mark.spot -@pytest.mark.maker_taker -@pytest.mark.e2e -@pytest.mark.asyncio -async def test_spot_maker_taker_matching( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - End-to-end test for spot trading using TWO separate accounts. - - This test requires a controlled environment to verify balance changes - between our maker and taker accounts. When external liquidity exists, - we skip to avoid unpredictable balance changes. - - This tests: - 1. Maker places GTC limit order - 2. Taker sends IOC order that matches - 3. Verify order matching occurs - 4. Check all relevant endpoints: - - spotExecutions (REST + WS) - - balances (REST + WS) - - orderChanges (WS) - - L2 depth - """ - logger.info("=" * 80) - logger.info(f"SPOT TRADING E2E TEST: {spot_config.symbol}") - logger.info("=" * 80) - logger.info(f"🏭 Maker Account: {maker_tester.account_id}") - logger.info(f"🎯 Taker Account: {taker_tester.account_id}") - logger.info(f"Using oracle price for orders: ${spot_config.oracle_price:.2f}") - - # Clear any existing orders for BOTH accounts - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Check current order book state - await spot_config.refresh_order_book(maker_tester.data) - - # Skip if external liquidity exists - this test needs controlled environment for balance verification - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping E2E maker-taker test: external liquidity exists. " - "This test requires a controlled environment to verify balance changes." - ) - - # Get initial balances for both accounts - logger.info("\n📊 Getting initial balances...") - maker_initial_balances = await maker_tester.data.balances() - taker_initial_balances = await taker_tester.data.balances() - logger.info(f"Maker initial balances: {[(b.asset, b.real_balance) for b in maker_initial_balances.values()]}") - logger.info(f"Taker initial balances: {[(b.asset, b.real_balance) for b in taker_initial_balances.values()]}") - - # Step 1: Place maker order (GTC buy within oracle deviation) - logger.info("\n📋 Step 1: Placing maker order (GTC buy)...") - maker_price = spot_config.price(0.99) - - maker_order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - - maker_order_id = await maker_tester.orders.create_limit(maker_order_params) - logger.info(f"Created maker order with ID: {maker_order_id} at price ${maker_price:.2f}") - - # Wait for maker order creation confirmation - await maker_tester.wait.for_order_creation(maker_order_id) - expected_maker_order = limit_order_params_to_order(maker_order_params, maker_tester.account_id) - await maker_tester.check.open_order_created(maker_order_id, expected_maker_order) - logger.info("✅ Maker order confirmed on the book") - - # Step 2: Check L2 depth to verify maker order is visible - logger.info("\n📊 Step 2: Checking L2 depth...") - await asyncio.sleep(0.05) - - depth = await maker_tester.data.market_depth(spot_config.symbol) - assert isinstance(depth, Depth), f"Expected Depth type, got {type(depth)}" - logger.info(f"Market depth type: {depth.type}") - logger.info(f"Bids: {len(depth.bids)} levels") - logger.info(f"Asks: {len(depth.asks)} levels") - - # Verify our maker order appears in the bids (using typed Level.px/qty attributes) - bids = depth.bids - maker_order_found = False - for bid in bids: - bid_price = float(bid.px) - bid_qty = float(bid.qty) - logger.info(f" Bid: ${bid_price:.2f} x {bid_qty:.6f}") - if abs(bid_price - maker_price) < 0.01: - maker_order_found = True - logger.info(f" ✅ Found our maker order in L2 depth at ${bid_price:.2f}") - - if not maker_order_found: - logger.warning(f"⚠️ Maker order at ${maker_price:.2f} not found in L2 depth - may have been matched already") - else: - logger.info("✅ Maker order visible in L2 depth") - - # Step 3: Place taker order (IOC sell at maker price to guarantee match) - logger.info("\n📋 Step 3: Placing taker order (IOC sell to match maker)...") - taker_price = maker_price # Same price ensures within oracle deviation - - # Clear balance updates before placing order - maker_tester.ws.clear_balance_updates() - taker_tester.ws.clear_balance_updates() - - taker_order_params = OrderBuilder.from_config(spot_config).sell().at_price(0.99).ioc().build() - - taker_order_id = await taker_tester.orders.create_limit(taker_order_params) - logger.info(f"Created taker order with ID: {taker_order_id} at price ${taker_price:.2f}") - - # Step 4: Wait for spot execution confirmation - logger.info("\n⏳ Step 4: Waiting for spot execution...") - expected_taker_order = limit_order_params_to_order(taker_order_params, taker_tester.account_id) - - # Strict matching on order_id and all fields - taker_execution = await taker_tester.wait.for_spot_execution(taker_order_id, expected_taker_order) - logger.info(f"✅ Taker execution confirmed: {taker_execution.order_id}") - - await taker_tester.check.spot_execution(taker_execution, expected_taker_order) - logger.info("✅ Taker execution details validated") - - # Step 5: Verify maker order was filled - logger.info("\n📋 Step 5: Checking maker order status...") - try: - await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Maker order fully filled") - except RuntimeError: - logger.info("⚠️ Maker order might be partially filled or still open") - - # Step 6: Verify balances changed appropriately - logger.info("\n💰 Step 6: Verifying balance changes...") - await asyncio.sleep(0.1) - - maker_final_balances = await maker_tester.data.balances() - taker_final_balances = await taker_tester.data.balances() - - # Get base and quote assets from config - base_asset = spot_config.base_asset - quote_asset = "RUSD" - - maker_tester.ws.verify_spot_trade_balance_changes( - maker_initial_balances=maker_initial_balances, - maker_final_balances=maker_final_balances, - taker_initial_balances=taker_initial_balances, - taker_final_balances=taker_final_balances, - base_asset=base_asset, - quote_asset=quote_asset, - qty=spot_config.min_qty, - price=str(maker_price), - is_maker_buyer=True, - ) - - # Verify balance updates via WebSocket - # Each tester has its own WebSocket connection subscribed to its own wallet - logger.info("\n💰 Verifying balance updates via WebSocket...") - maker_balance_updates = [b for b in maker_tester.ws.balance_updates if b.account_id == maker_tester.account_id] - taker_balance_updates = [b for b in taker_tester.ws.balance_updates if b.account_id == taker_tester.account_id] - - logger.info(f"Maker received {len(maker_balance_updates)} balance updates via WS") - logger.info(f"Taker received {len(taker_balance_updates)} balance updates via WS") - - assert ( - len(maker_balance_updates) == 2 - ), f"Maker should receive exactly 2 balance updates ({base_asset} + RUSD), got {len(maker_balance_updates)}" - assert ( - len(taker_balance_updates) == 2 - ), f"Taker should receive exactly 2 balance updates ({base_asset} + RUSD), got {len(taker_balance_updates)}" - - maker_assets = {b.asset for b in maker_balance_updates} - taker_assets = {b.asset for b in taker_balance_updates} - logger.info(f"✅ Maker balance updates received for: {maker_assets}") - logger.info(f"✅ Taker balance updates received for: {taker_assets}") - - assert maker_assets == { - base_asset, - "RUSD", - }, f"Maker should have both {base_asset} and RUSD updates, got {maker_assets}" - assert taker_assets == { - base_asset, - "RUSD", - }, f"Taker should have both {base_asset} and RUSD updates, got {taker_assets}" - - logger.info("✅ Balance updates verified via WebSocket for both accounts") - - # Step 7: Verify order changes via WebSocket - logger.info("\n📨 Step 7: Verifying order changes via WebSocket...") - assert maker_order_id is not None, "Maker order_id should not be None" - assert maker_order_id in maker_tester.ws.order_changes, "Maker order should be in WS order changes" - if taker_order_id is not None and taker_order_id in taker_tester.ws.order_changes: - logger.info("✅ Taker order changes received via WebSocket") - logger.info("✅ Order changes verification completed") - - # Step 8: Verify spot execution via WebSocket - logger.info("\n📊 Step 8: Verifying spot execution via WebSocket...") - assert taker_tester.ws.last_spot_execution is not None, "Should have received spot execution via WS" - assert taker_tester.ws.last_spot_execution.symbol == spot_config.symbol - logger.info("✅ Spot execution received via WebSocket") - - # Cleanup - logger.info("\n🧹 Cleanup: Cancelling any remaining orders...") - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Verify no open orders remain - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("\n%s", "=" * 80) - logger.info("✅ SPOT TRADING E2E TEST COMPLETED SUCCESSFULLY") - logger.info("=" * 80) diff --git a/tests/test_spot/test_order_cancellation.py b/tests/test_spot/test_order_cancellation.py deleted file mode 100644 index 1e5ff1d2..00000000 --- a/tests/test_spot/test_order_cancellation.py +++ /dev/null @@ -1,454 +0,0 @@ -""" -Tests for spot order cancellation. - -These tests verify single order cancellation and mass cancel functionality -for spot markets. -""" - -import asyncio -import time - -import pytest - -from sdk.open_api.exceptions import ApiException -from sdk.open_api.models.order_status import OrderStatus -from tests.helpers import ReyaTester -from tests.helpers.builders import OrderBuilder -from tests.helpers.reya_tester import limit_order_params_to_order, logger -from tests.test_spot.spot_config import SpotTestConfig - - -@pytest.mark.spot -@pytest.mark.cancel -@pytest.mark.asyncio -async def test_spot_order_cancellation(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test placing and cancelling a spot GTC order before it gets filled. - """ - logger.info("=" * 80) - logger.info(f"SPOT ORDER CANCELLATION TEST: {spot_config.symbol}") - logger.info("=" * 80) - logger.info(f"Using reference price for orders: ${spot_config.oracle_price}") - - # Clear any existing orders - await spot_tester.orders.close_all(fail_if_none=False) - - # Place GTC order far from reference (won't fill) - buy_price = spot_config.price(0.96) # Far below reference - - order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() - - logger.info(f"Placing GTC buy order at ${buy_price:.2f} (far from market)...") - order_id = await spot_tester.orders.create_limit(order_params) - logger.info(f"Created order with ID: {order_id}") - - # Wait for order creation - await spot_tester.wait.for_order_creation(order_id) - expected_order = limit_order_params_to_order(order_params, spot_tester.account_id) - await spot_tester.check.open_order_created(order_id, expected_order) - logger.info("✅ Order confirmed on the book") - - # Cancel the order - logger.info("Cancelling order...") - await spot_tester.client.cancel_order( - order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - - # Wait for cancellation confirmation - cancelled_order_id = await spot_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED) - assert cancelled_order_id == order_id, "Order was not cancelled" - logger.info("✅ Order cancelled successfully") - - # Verify no open orders remain - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT ORDER CANCELLATION TEST COMPLETED SUCCESSFULLY") - - -@pytest.mark.spot -@pytest.mark.cancel -@pytest.mark.asyncio -async def test_spot_mass_cancel(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test placing multiple spot GTC orders and then cancelling them all via mass cancel. - """ - num_orders = 5 - - logger.info("=" * 80) - logger.info(f"SPOT MASS CANCEL TEST: {spot_config.symbol}") - logger.info("=" * 80) - logger.info(f"Using reference price for orders: ${spot_config.oracle_price}") - - # Clear any existing orders - await spot_tester.orders.close_all(fail_if_none=False) - - # Place multiple GTC orders at different prices (far from market, won't fill) - order_ids = [] - - logger.info(f"\n📋 Step 1: Placing {num_orders} GTC orders...") - for i in range(num_orders): - # Space orders within 5% of oracle price (0.96 to 0.98) - price_factor = 0.96 + (i * 0.005) - buy_price = round(spot_config.oracle_price * price_factor, 2) - - order_params = OrderBuilder.from_config(spot_config).buy().price(str(buy_price)).gtc().build() - - logger.info(f"Creating order {i + 1}/{num_orders} at ${buy_price:.2f}") - order_id = await spot_tester.orders.create_limit(order_params) - order_ids.append(order_id) - - # Wait for order creation - await spot_tester.wait.for_order_creation(order_id) - logger.info(f"✅ Order {i + 1} created: {order_id}") - - logger.info(f"\n✅ All {num_orders} orders created successfully") - - # Verify all orders are on the book - logger.info("\n📊 Step 2: Verifying all orders are on the book...") - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - - for order_id in order_ids: - assert order_id in open_order_ids, f"Order {order_id} not found on the book" - - logger.info(f"✅ Verified {len(order_ids)} orders on the book") - - # Mass cancel all orders for this symbol - logger.info("\n🧹 Step 3: Mass cancelling all orders...") - response = await spot_tester.client.mass_cancel(symbol=spot_config.symbol, account_id=spot_tester.account_id) - logger.info(f"Mass cancel response: {response}") - - # Wait a moment for cancellations to propagate - await asyncio.sleep(0.1) - - # Verify all orders are cancelled - logger.info("\n📊 Step 4: Verifying all orders are cancelled...") - open_orders_after = await spot_tester.client.get_open_orders() - open_order_ids_after = [o.order_id for o in open_orders_after if o.symbol == spot_config.symbol] - - for order_id in order_ids: - assert order_id not in open_order_ids_after, f"Order {order_id} still exists after mass cancel" - - logger.info(f"✅ All {num_orders} orders successfully cancelled via mass cancel") - - # Final verification - no open orders remain - await spot_tester.check.no_open_orders() - - logger.info("\n%s", "=" * 80) - logger.info("✅ SPOT MASS CANCEL TEST COMPLETED SUCCESSFULLY") - logger.info("=" * 80) - - -@pytest.mark.spot -@pytest.mark.cancel -@pytest.mark.asyncio -async def test_spot_cancel_nonexistent_order(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test cancelling an order that doesn't exist. - - Flow: - 1. Attempt to cancel a non-existent order ID - 2. Verify error response is returned - """ - logger.info("=" * 80) - logger.info(f"SPOT CANCEL NONEXISTENT ORDER TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Use a fake order ID that doesn't exist - fake_order_id = "999999999999999999" - - logger.info(f"Attempting to cancel non-existent order: {fake_order_id}") - - try: - await spot_tester.client.cancel_order( - order_id=fake_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - # If we get here, the API might accept the request but do nothing - logger.info("Cancel request accepted (order may not exist)") - except ApiException as e: - logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") - - # Verify no open orders remain - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT CANCEL NONEXISTENT ORDER TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.cancel -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_spot_cancel_already_filled_order( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Test cancelling an order that was already filled. - - Flow: - 1. Maker places GTC sell order (maker has more ETH) - 2. Taker buys with IOC (taker has more RUSD) - 3. Attempt to cancel the filled order - 4. Verify error response (order already filled) - """ - # Check current order book state - await spot_config.refresh_order_book(taker_tester.data) - - logger.info("=" * 80) - logger.info(f"SPOT CANCEL ALREADY FILLED ORDER TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Determine how to get a filled order based on liquidity - if spot_config.has_usable_ask_liquidity: - # External asks exist - taker buys from external - ask_price = spot_config.best_ask_price - assert ask_price is not None - trade_price = float(ask_price) - logger.info(f"Using external ask liquidity at ${trade_price:.2f}") - - # Place GTC buy order that will match external asks - taker_params = OrderBuilder.from_config(spot_config).buy().price(str(ask_price)).gtc().build() - filled_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker placing GTC buy: {spot_config.min_qty} @ ${trade_price:.2f}") - - # Wait for fill - await taker_tester.wait.for_order_state(filled_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Order filled by external liquidity") - - # Now try to cancel the already-filled order - logger.info(f"Attempting to cancel already-filled order: {filled_order_id}") - - try: - await taker_tester.client.cancel_order( - order_id=filled_order_id, symbol=spot_config.symbol, account_id=taker_tester.account_id - ) - logger.info("Cancel request accepted (order already filled)") - except ApiException as e: - logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") - elif spot_config.has_usable_bid_liquidity: - # External bids exist - taker sells to external - bid_price = spot_config.best_bid_price - assert bid_price is not None - trade_price = float(bid_price) - logger.info(f"Using external bid liquidity at ${trade_price:.2f}") - - # Place GTC sell order that will match external bids - taker_params = OrderBuilder.from_config(spot_config).sell().price(str(bid_price)).gtc().build() - filled_order_id = await taker_tester.orders.create_limit(taker_params) - logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${trade_price:.2f}") - - # Wait for fill - await taker_tester.wait.for_order_state(filled_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Order filled by external liquidity") - - # Now try to cancel the already-filled order - logger.info(f"Attempting to cancel already-filled order: {filled_order_id}") - - try: - await taker_tester.client.cancel_order( - order_id=filled_order_id, symbol=spot_config.symbol, account_id=taker_tester.account_id - ) - logger.info("Cancel request accepted (order already filled)") - except ApiException as e: - logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") - else: - # No external liquidity - use maker-taker matching - maker_price = spot_config.price(1.04) - - maker_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() - logger.info(f"Maker placing GTC sell: {spot_config.min_qty} @ ${maker_price:.2f}") - filled_order_id = await maker_tester.orders.create_limit(maker_params) - await maker_tester.wait.for_order_creation(filled_order_id) - logger.info(f"✅ Maker order created: {filled_order_id}") - - # Taker buys with IOC - taker_params = OrderBuilder.from_config(spot_config).buy().at_price(1.04).ioc().build() - logger.info("Taker placing IOC buy to fill maker order...") - await taker_tester.orders.create_limit(taker_params) - - # Wait for fill - await asyncio.sleep(0.05) - await maker_tester.wait.for_order_state(filled_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Maker order filled") - - # Now try to cancel the already-filled order - logger.info(f"Attempting to cancel already-filled order: {filled_order_id}") - - try: - await maker_tester.client.cancel_order( - order_id=filled_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id - ) - logger.info("Cancel request accepted (order already filled)") - except ApiException as e: - logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") - - # Verify no open orders - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ SPOT CANCEL ALREADY FILLED ORDER TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.cancel -@pytest.mark.asyncio -async def test_spot_mass_cancel_empty_book(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test mass cancel when no orders exist. - - Flow: - 1. Ensure no orders exist - 2. Call mass cancel - 3. Verify success with count=0 (or no error) - """ - logger.info("=" * 80) - logger.info(f"SPOT MASS CANCEL EMPTY BOOK TEST: {spot_config.symbol}") - logger.info("=" * 80) - - # Ensure no orders exist - await spot_tester.orders.close_all(fail_if_none=False) - await spot_tester.check.no_open_orders() - - logger.info("Calling mass cancel on empty book...") - - try: - response = await spot_tester.client.mass_cancel(symbol=spot_config.symbol, account_id=spot_tester.account_id) - logger.info(f"✅ Mass cancel succeeded: {response}") - except ApiException as e: - # Some APIs might return an error for empty cancel - logger.info(f"Mass cancel response: {type(e).__name__}") - - # Verify still no orders - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT MASS CANCEL EMPTY BOOK TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.cancel -@pytest.mark.asyncio -async def test_spot_cancel_by_client_order_id(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test cancelling an order using client order ID instead of order ID. - - Flow: - 1. Place GTC order with a specific client order ID - 2. Cancel the order using the client order ID - 3. Verify order is cancelled - """ - logger.info("=" * 80) - logger.info(f"SPOT CANCEL BY CLIENT ORDER ID TEST: {spot_config.symbol}") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Generate a unique client order ID (must be an integer) - client_order_id = int(time.time() * 1000) % (2**31 - 1) # Keep within int32 range - - # Place GTC order with client order ID - order_price = spot_config.price(0.96) - - order_params = ( - OrderBuilder() - .symbol(spot_config.symbol) - .buy() - .price(str(order_price)) - .qty(spot_config.min_qty) - .gtc() - .client_order_id(client_order_id) - .build() - ) - - logger.info(f"Placing GTC order with clientOrderId: {client_order_id}") - order_id = await spot_tester.orders.create_limit(order_params) - await spot_tester.wait.for_order_creation(order_id) - logger.info(f"✅ Order created: {order_id}") - - # Verify order is on the book - open_orders = await spot_tester.client.get_open_orders() - order_found = False - for o in open_orders: - if o.order_id == order_id: - order_found = True - if hasattr(o, "client_order_id") and o.client_order_id: - logger.info(f"Order has clientOrderId: {o.client_order_id}") - break - - assert order_found, f"Order {order_id} not found on the book" - - # Cancel using client order ID - logger.info(f"Cancelling order using clientOrderId: {client_order_id}") - - try: - await spot_tester.client.cancel_order( - order_id=order_id, # Some APIs require order_id even with client_order_id - symbol=spot_config.symbol, - account_id=spot_tester.account_id, - client_order_id=client_order_id, - ) - logger.info("✅ Cancel request sent with clientOrderId") - except TypeError: - # If client_order_id is not supported, fall back to order_id - logger.info("clientOrderId not supported in cancel, using order_id") - await spot_tester.client.cancel_order( - order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - - # Wait for cancellation - await spot_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED) - logger.info("✅ Order cancelled successfully") - - # Verify no open orders - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT CANCEL BY CLIENT ORDER ID TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.cancel -@pytest.mark.asyncio -async def test_spot_mass_cancel_no_orders(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test mass cancel when no orders exist for the account/market. - - Flow: - 1. Ensure no open orders exist - 2. Execute mass cancel - 3. Verify response indicates 0 orders cancelled - 4. Verify no errors are raised - """ - logger.info("=" * 80) - logger.info(f"SPOT MASS CANCEL NO ORDERS TEST: {spot_config.symbol}") - logger.info("=" * 80) - - # Ensure no open orders exist - await spot_tester.orders.close_all(fail_if_none=False) - await spot_tester.check.no_open_orders() - logger.info("✅ Confirmed no open orders exist") - - # Execute mass cancel on empty order book (for this account) - logger.info("Executing mass cancel with no orders...") - response = await spot_tester.client.mass_cancel(symbol=spot_config.symbol, account_id=spot_tester.account_id) - - # Verify response - logger.info(f"Mass cancel response: {response}") - - # The response should indicate 0 orders were cancelled - if hasattr(response, "cancelled_count"): - assert response.cancelled_count == 0, f"Expected 0 cancelled orders, got {response.cancelled_count}" - logger.info("✅ Response correctly shows 0 orders cancelled") - elif hasattr(response, "cancelledCount"): - assert response.cancelledCount == 0, f"Expected 0 cancelled orders, got {response.cancelledCount}" - logger.info("✅ Response correctly shows 0 orders cancelled") - else: - # If response doesn't have count, just verify no error was raised - logger.info("✅ Mass cancel succeeded without error (no count in response)") - - # Verify still no open orders - await spot_tester.check.no_open_orders() - - logger.info("✅ SPOT MASS CANCEL NO ORDERS TEST COMPLETED") diff --git a/tests/test_spot/test_self_match_prevention.py b/tests/test_spot/test_self_match_prevention.py deleted file mode 100644 index 52da5c90..00000000 --- a/tests/test_spot/test_self_match_prevention.py +++ /dev/null @@ -1,871 +0,0 @@ -""" -Comprehensive tests for spot self-match prevention. - -The matching engine prevents orders from the same account from matching -against each other. When self-match is detected, the TAKER order is cancelled -and the MAKER order remains on the book. - -Test Categories: -1. Basic Self-Match Prevention (GTC and IOC takers) -2. Price Boundary Cases (exact price, crossing prices) -3. Quantity Scenarios (partial qty, different sizes) -4. Market Maker Scenarios (multiple levels, non-crossing orders) -5. Cross-Account Matching (sanity checks that matching works between accounts) - -NOTE: Self-match prevention tests require a controlled environment where our -maker order is the only liquidity at the test price. When external liquidity -exists at crossing prices, these tests are skipped to avoid false failures. -""" - -import asyncio -from decimal import Decimal - -import pytest - -from sdk.open_api.exceptions import ApiException -from sdk.open_api.models.order_status import OrderStatus -from tests.helpers import ReyaTester -from tests.helpers.builders import OrderBuilder -from tests.helpers.reya_tester import limit_order_params_to_order, logger -from tests.test_spot.spot_config import SpotTestConfig - - -async def _skip_if_external_liquidity_exists(spot_config: SpotTestConfig, tester: ReyaTester) -> None: - """ - Skip the test if external liquidity exists that could interfere with self-match tests. - - Self-match tests need a controlled environment where our maker order is the only - liquidity at the test price. If external liquidity exists, the taker order might - match against it instead of triggering self-match prevention. - """ - await spot_config.refresh_order_book(tester.data) - - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping self-match test: external liquidity exists in order book. " - "Self-match tests require a controlled environment." - ) - - -# SECTION 1: Basic Self-Match Prevention -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_self_match_gtc_taker_sell_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - GTC buy maker + GTC sell taker (crossing) → taker cancelled. - - Flow: - 1. Place GTC buy order (becomes maker on book) - 2. Place GTC sell order at crossing price from SAME account (taker) - 3. Verify taker is CANCELLED, maker remains OPEN - """ - logger.info("=" * 80) - logger.info("TEST: GTC taker sell cancelled on self-match") - logger.info("=" * 80) - - # Skip if external liquidity exists - await _skip_if_external_liquidity_exists(spot_config, spot_tester) - - await spot_tester.orders.close_all(fail_if_none=False) - - maker_price = spot_config.price(0.97) - _ = maker_price # taker_price - would cross - - # Place maker buy - maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() - maker_order_id = await spot_tester.orders.create_limit(maker_params) - await spot_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker buy: {maker_order_id} at ${maker_price:.2f}") - - # Place taker sell (same account, crossing) - taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).gtc().build() - taker_order_id = await spot_tester.orders.create_limit(taker_params) - await asyncio.sleep(0.1) - - # Verify - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - - assert taker_order_id not in open_order_ids, "Taker should be CANCELLED" - assert maker_order_id in open_order_ids, "Maker should remain OPEN" - logger.info("✅ Taker cancelled, maker remains open") - - # Cleanup - await spot_tester.client.cancel_order( - order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_self_match_gtc_taker_buy_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - GTC sell maker + GTC buy taker (crossing) → taker cancelled. - - Flow: - 1. Place GTC sell order (becomes maker on book) - 2. Place GTC buy order at crossing price from SAME account (taker) - 3. Verify taker is CANCELLED, maker remains OPEN - """ - logger.info("=" * 80) - logger.info("TEST: GTC taker buy cancelled on self-match") - logger.info("=" * 80) - - # Skip if external liquidity exists - await _skip_if_external_liquidity_exists(spot_config, spot_tester) - - await spot_tester.orders.close_all(fail_if_none=False) - - maker_price = spot_config.price(0.97) - _ = maker_price # taker_price - would cross - - # Place maker sell - maker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).gtc().build() - maker_order_id = await spot_tester.orders.create_limit(maker_params) - await spot_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker sell: {maker_order_id} at ${maker_price:.2f}") - - # Place taker buy (same account, crossing) - taker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() - taker_order_id = await spot_tester.orders.create_limit(taker_params) - await asyncio.sleep(0.1) - - # Verify - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - - assert taker_order_id not in open_order_ids, "Taker should be CANCELLED" - assert maker_order_id in open_order_ids, "Maker should remain OPEN" - logger.info("✅ Taker cancelled, maker remains open") - - # Cleanup - await spot_tester.client.cancel_order( - order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_self_match_ioc_taker_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - GTC buy maker + IOC sell taker (crossing) → IOC taker cancelled, no execution. - - Flow: - 1. Place GTC buy order (becomes maker on book) - 2. Send IOC sell order at crossing price from SAME account (taker) - 3. Verify IOC taker is cancelled, no execution occurs - 4. Verify GTC maker remains open on the book - """ - logger.info("=" * 80) - logger.info("TEST: IOC taker cancelled on self-match") - logger.info("=" * 80) - - # Skip if external liquidity exists - await _skip_if_external_liquidity_exists(spot_config, spot_tester) - - await spot_tester.orders.close_all(fail_if_none=False) - spot_tester.ws.last_spot_execution = None - - maker_price = spot_config.price(0.97) - _ = maker_price # taker_price - calculated for reference - - # Place GTC maker buy - maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() - maker_order_id = await spot_tester.orders.create_limit(maker_params) - await spot_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ GTC maker buy: {maker_order_id}") - - # Send IOC taker sell (same account, crossing) - taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).ioc().build() - - try: - await spot_tester.orders.create_limit(taker_params) - await asyncio.sleep(0.1) - assert spot_tester.ws.last_spot_execution is None, "No execution should occur" - logger.info("✅ No execution - IOC cancelled") - except ApiException as e: - logger.info(f"✅ IOC rejected (self-match prevented): {type(e).__name__}") - - # Verify maker remains - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - assert maker_order_id in open_order_ids, "Maker should remain open" - logger.info("✅ GTC maker remains open") - - # Cleanup - await spot_tester.client.cancel_order( - order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - -# ============================================================================= -# SECTION 2: Price Boundary Cases -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_self_match_exact_price_boundary(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Exact same price from same account triggers self-match prevention. - - When buy and sell are at the exact same price from the same account, - this is considered "in cross" and should trigger self-match prevention. - """ - logger.info("=" * 80) - logger.info("TEST: Exact price boundary triggers self-match") - logger.info("=" * 80) - - # Skip if external liquidity exists - await _skip_if_external_liquidity_exists(spot_config, spot_tester) - - await spot_tester.orders.close_all(fail_if_none=False) - spot_tester.ws.last_spot_execution = None - - exact_price = spot_config.price(0.97) - - # Place maker sell - maker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).gtc().build() - maker_order_id = await spot_tester.orders.create_limit(maker_params) - await spot_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker sell at ${exact_price:.2f}") - - # Place taker buy at EXACT same price - taker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() - taker_order_id = await spot_tester.orders.create_limit(taker_params) - await asyncio.sleep(0.1) - - # Verify - assert spot_tester.ws.last_spot_execution is None, "No execution at exact price" - - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - - assert taker_order_id not in open_order_ids, "Taker should be cancelled" - assert maker_order_id in open_order_ids, "Maker should remain" - logger.info("✅ Self-match prevented at exact price boundary") - - # Cleanup - await spot_tester.client.cancel_order( - order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_non_crossing_orders_no_self_match(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Non-crossing orders from same account are NOT self-match. - - Same account places sell at high price, then buy at low price. - Since prices don't cross, both orders should be on the book. - """ - logger.info("=" * 80) - logger.info("TEST: Non-crossing orders are not self-match") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - sell_price = spot_config.price(1.02) # 10% above - buy_price = spot_config.price(0.99) # 10% below - - # Place sell - sell_params = OrderBuilder.from_config(spot_config).sell().at_price(1.02).gtc().build() - sell_order_id = await spot_tester.orders.create_limit(sell_params) - await spot_tester.wait.for_order_creation(sell_order_id) - logger.info(f"✅ Sell at ${sell_price:.2f}") - - # Place buy (non-crossing) - buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() - buy_order_id = await spot_tester.orders.create_limit(buy_params) - await spot_tester.wait.for_order_creation(buy_order_id) - logger.info(f"✅ Buy at ${buy_price:.2f}") - - # Verify both are on book - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - - assert sell_order_id in open_order_ids, "Sell should be on book" - assert buy_order_id in open_order_ids, "Buy should be on book" - logger.info("✅ Both non-crossing orders are on the book") - - # Cleanup - for order_id in [sell_order_id, buy_order_id]: - await spot_tester.client.cancel_order( - order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_non_crossing_ioc_cancelled_no_match(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Non-crossing IOC is cancelled due to no match, NOT self-match. - - Same account places sell at high price, then IOC buy at low price. - The IOC should be cancelled because there's no match available. - """ - logger.info("=" * 80) - logger.info("TEST: Non-crossing IOC cancelled (no match)") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - spot_tester.ws.last_spot_execution = None - - sell_price = spot_config.price(1.04) - _ = spot_config.price(0.96) # buy_price - calculated for reference - - # Place GTC sell - sell_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() - sell_order_id = await spot_tester.orders.create_limit(sell_params) - await spot_tester.wait.for_order_creation(sell_order_id) - logger.info(f"✅ GTC sell at ${sell_price:.2f}") - - # Place IOC buy (non-crossing) - buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).ioc().build() - - try: - await spot_tester.orders.create_limit(buy_params) - await asyncio.sleep(0.1) - assert spot_tester.ws.last_spot_execution is None, "No execution" - logger.info("✅ IOC cancelled - no match available") - except ApiException as e: - logger.info(f"✅ IOC rejected (no match): {type(e).__name__}") - - # Verify GTC sell remains - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - assert sell_order_id in open_order_ids, "GTC sell should remain" - - # Cleanup - await spot_tester.client.cancel_order( - order_id=sell_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - -# ============================================================================= -# SECTION 3: Quantity Scenarios -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_self_match_partial_qty_taker_fully_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Self-match with different quantities: taker is FULLY cancelled. - - When taker would partially match a self-order, the ENTIRE taker - is cancelled (not just the self-matching portion). - """ - logger.info("=" * 80) - logger.info("TEST: Partial qty self-match - taker fully cancelled") - logger.info("=" * 80) - - # Skip if external liquidity exists - await _skip_if_external_liquidity_exists(spot_config, spot_tester) - - await spot_tester.orders.close_all(fail_if_none=False) - spot_tester.ws.last_spot_execution = None - - order_price = spot_config.price(0.97) - maker_qty = "0.02" # Use smaller qty to conserve funds - taker_qty = "0.01" # Minimum order size - - # Place maker sell with large qty - maker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).qty(maker_qty).gtc().build() - maker_order_id = await spot_tester.orders.create_limit(maker_params) - await spot_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker sell: qty={maker_qty}") - - # Place taker buy with smaller qty - taker_params = ( - OrderBuilder.from_config(spot_config) - .buy() - .price(str(round(order_price * 1.01, 2))) - .qty(taker_qty) - .gtc() - .build() - ) - taker_order_id = await spot_tester.orders.create_limit(taker_params) - await asyncio.sleep(0.1) - - # Verify no execution - assert spot_tester.ws.last_spot_execution is None, "No execution" - - # Verify taker cancelled, maker unchanged - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - - assert taker_order_id not in open_order_ids, "Taker should be cancelled" - assert maker_order_id in open_order_ids, "Maker should remain" - - # Verify maker qty unchanged - maker_order = next(o for o in open_orders if o.order_id == maker_order_id) - assert maker_order.qty == maker_qty, f"Maker qty should be {maker_qty}" - logger.info(f"✅ Taker fully cancelled, maker unchanged (qty={maker_order.qty})") - - # Cleanup - await spot_tester.client.cancel_order( - order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_self_match_larger_taker_fully_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Self-match with larger taker: taker is FULLY cancelled. - - Even when taker qty > maker qty, the entire taker is cancelled. - """ - logger.info("=" * 80) - logger.info("TEST: Larger taker self-match - fully cancelled") - logger.info("=" * 80) - - # Skip if external liquidity exists - await _skip_if_external_liquidity_exists(spot_config, spot_tester) - - await spot_tester.orders.close_all(fail_if_none=False) - spot_tester.ws.last_spot_execution = None - - order_price = spot_config.price(0.97) - maker_qty = "0.01" # Minimum order size - taker_qty = "0.02" # Slightly larger to test larger taker scenario - - # Place maker sell with smaller qty - maker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).qty(maker_qty).gtc().build() - maker_order_id = await spot_tester.orders.create_limit(maker_params) - await spot_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker sell: qty={maker_qty}") - - # Place taker buy with larger qty - taker_params = ( - OrderBuilder.from_config(spot_config) - .buy() - .price(str(round(order_price * 1.01, 2))) - .qty(taker_qty) - .gtc() - .build() - ) - taker_order_id = await spot_tester.orders.create_limit(taker_params) - await asyncio.sleep(0.1) - - # Verify no execution - assert spot_tester.ws.last_spot_execution is None, "No execution" - - # Verify taker cancelled, maker unchanged - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - - assert taker_order_id not in open_order_ids, "Taker should be cancelled" - assert maker_order_id in open_order_ids, "Maker should remain" - logger.info("✅ Larger taker fully cancelled") - - # Cleanup - await spot_tester.client.cancel_order( - order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.05) - await spot_tester.check.no_open_orders() - - -# ============================================================================= -# SECTION 4: Market Maker Scenarios -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_market_maker_multiple_non_crossing_levels(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Market maker scenario: same account has multiple price levels on both sides. - - This was a previously problematic scenario - placing multiple sell orders - at different prices, then multiple buy orders at different prices - (all non-crossing). All orders should be added to the book. - """ - logger.info("=" * 80) - logger.info("TEST: Market maker multiple non-crossing levels") - logger.info("=" * 80) - - await spot_tester.orders.close_all(fail_if_none=False) - - # Sells: 102%, 104%, 106% of reference - # Buys: 98%, 96%, 94% of reference - sell_prices = [round(spot_config.oracle_price * (1.02 + i * 0.02), 2) for i in range(3)] - buy_prices = [round(spot_config.oracle_price * (0.98 - i * 0.02), 2) for i in range(3)] - - sell_order_ids = [] - buy_order_ids = [] - - # Place 3 sell orders - logger.info("Placing 3 sell orders at increasing prices...") - for i, price in enumerate(sell_prices): - params = OrderBuilder.from_config(spot_config).sell().price(str(price)).gtc().build() - order_id = await spot_tester.orders.create_limit(params) - await spot_tester.wait.for_order_creation(order_id) - sell_order_ids.append(order_id) - logger.info(f" Sell {i + 1}: ${price:.2f}") - - # Place 3 buy orders - logger.info("Placing 3 buy orders at decreasing prices...") - for i, price in enumerate(buy_prices): - params = OrderBuilder.from_config(spot_config).buy().price(str(price)).gtc().build() - order_id = await spot_tester.orders.create_limit(params) - await spot_tester.wait.for_order_creation(order_id) - buy_order_ids.append(order_id) - logger.info(f" Buy {i + 1}: ${price:.2f}") - - # Verify all 6 orders on book - await asyncio.sleep(0.1) - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - - for order_id in sell_order_ids: - assert order_id in open_order_ids, f"Sell {order_id} should be on book" - for order_id in buy_order_ids: - assert order_id in open_order_ids, f"Buy {order_id} should be on book" - logger.info("✅ All 6 orders are on the book") - - # Cleanup - for order_id in sell_order_ids + buy_order_ids: - await spot_tester.client.cancel_order( - order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.1) - await spot_tester.check.no_open_orders() - - -@pytest.mark.spot -@pytest.mark.asyncio -async def test_multiple_self_matches_in_sequence(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Multiple potential self-matches in sequence. - - Place multiple maker orders from same account, then send a taker - that would cross multiple of them. Taker should be cancelled on - first self-match detection. - """ - logger.info("=" * 80) - logger.info("TEST: Multiple self-matches in sequence") - logger.info("=" * 80) - - # Skip if external liquidity exists - await _skip_if_external_liquidity_exists(spot_config, spot_tester) - - await spot_tester.orders.close_all(fail_if_none=False) - spot_tester.ws.last_spot_execution = None - - base_price = spot_config.price(0.97) - maker_order_ids = [] - - # Place 3 maker sells at increasing prices - for i in range(3): - price = round(base_price * (1 + i * 0.01), 2) - params = OrderBuilder.from_config(spot_config).sell().price(str(price)).gtc().build() - order_id = await spot_tester.orders.create_limit(params) - await spot_tester.wait.for_order_creation(order_id) - maker_order_ids.append(order_id) - logger.info(f" Maker sell {i + 1}: ${price:.2f}") - - # Place taker buy that would cross all makers - taker_price = round(base_price * 1.10, 2) # Above all makers - # Use Decimal arithmetic to avoid floating point precision issues - taker_qty = Decimal(spot_config.min_qty) * 3 - taker_params = ( - OrderBuilder() - .symbol(spot_config.symbol) - .buy() - .price(str(taker_price)) - .qty(str(taker_qty)) # Enough to match all - .gtc() - .build() - ) - taker_order_id = await spot_tester.orders.create_limit(taker_params) - await asyncio.sleep(0.1) - - # Verify no execution - assert spot_tester.ws.last_spot_execution is None, "No execution" - - # Verify taker cancelled, all makers remain - open_orders = await spot_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - - assert taker_order_id not in open_order_ids, "Taker should be cancelled" - for order_id in maker_order_ids: - assert order_id in open_order_ids, f"Maker {order_id} should remain" - logger.info("✅ Taker cancelled, all makers remain") - - # Cleanup - for order_id in maker_order_ids: - await spot_tester.client.cancel_order( - order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id - ) - await asyncio.sleep(0.1) - await spot_tester.check.no_open_orders() - - -# ============================================================================= -# SECTION 5: Partial Fill Then Self-Match Scenarios -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_partial_fill_then_self_match_cancels_remainder( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Taker partially fills against another account, then hits self-match. - - This is a critical edge case: - - Account 1 has BUY 10 @ 100 (best bid) - - Account 2 has BUY 10 @ 99 (second best bid) - - Account 2 sends SELL 15 @ 99 GTC - - Expected behavior: - 1. Account 2's SELL matches Account 1's BUY: 10 lots @ 100 (execution) - 2. Account 2's SELL (5 remaining) would match Account 2's BUY @ 99 (self-match!) - 3. Account 2's SELL is CANCELLED (5 lots remaining, not added to book) - 4. Account 2's BUY @ 99 remains on book (untouched) - - Result: 1 execution, taker cancelled after partial fill, self-order untouched. - """ - # Skip if external liquidity exists - this test requires controlled price levels - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping partial fill self-match test: external liquidity exists. " - "Test requires controlled environment for specific matching behavior." - ) - - logger.info("=" * 80) - logger.info("TEST: Partial fill then self-match cancels remainder") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Prices: Account 1 BUY @ 100, Account 2 BUY @ 99 - account1_buy_price = spot_config.price(0.97) # Best bid (higher) - account2_buy_price = spot_config.price(0.97) # Second best bid (lower) - account2_sell_price = account2_buy_price # Sell at same price as own buy - - fill_qty = spot_config.min_qty # Each order is this qty - taker_qty = str(float(spot_config.min_qty) * 2) # 2x to ensure partial fill + remainder (must be valid qty step) - - # Step 1: Account 1 (maker_tester) places BUY @ 100 (best bid) - account1_buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).qty(fill_qty).gtc().build() - account1_buy_id = await maker_tester.orders.create_limit(account1_buy_params) - await maker_tester.wait.for_order_creation(account1_buy_id) - logger.info(f"✅ Account 1 BUY: {account1_buy_id} @ ${account1_buy_price:.2f}") - - # Step 2: Account 2 (taker_tester) places BUY @ 99 (second best bid) - account2_buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).qty(fill_qty).gtc().build() - account2_buy_id = await taker_tester.orders.create_limit(account2_buy_params) - await taker_tester.wait.for_order_creation(account2_buy_id) - logger.info(f"✅ Account 2 BUY: {account2_buy_id} @ ${account2_buy_price:.2f}") - - # Verify both buys are on book - open_orders_maker = await maker_tester.client.get_open_orders() - open_orders_taker = await taker_tester.client.get_open_orders() - assert any(o.order_id == account1_buy_id for o in open_orders_maker), "Account 1 buy should be on book" - assert any(o.order_id == account2_buy_id for o in open_orders_taker), "Account 2 buy should be on book" - logger.info("✅ Both BUY orders on book") - - # Step 3: Account 2 sends SELL @ 99 with qty = 1.5x (will partially fill then self-match) - account2_sell_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).qty(taker_qty).gtc().build() - logger.info(f"Account 2 sending SELL @ ${account2_sell_price:.2f}, qty={taker_qty}...") - account2_sell_id = await taker_tester.orders.create_limit(account2_sell_params) - - # Wait for execution (strict matching on order_id and all fields) - expected_order = limit_order_params_to_order(account2_sell_params, taker_tester.account_id) - execution = await taker_tester.wait.for_spot_execution(account2_sell_id, expected_order, timeout=5) - logger.info(f"✅ Execution: Account 2 SELL matched Account 1 BUY, qty={execution.qty}") - - # Step 4: Verify Account 1's BUY is filled - await maker_tester.wait.for_order_state(account1_buy_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Account 1's BUY filled") - - # Step 5: Verify Account 2's SELL is CANCELLED (not on book, not partially filled on book) - await asyncio.sleep(0.2) # Allow time for order state to propagate - - open_orders_taker = await taker_tester.client.get_open_orders() - taker_order_ids = [o.order_id for o in open_orders_taker if o.symbol == spot_config.symbol] - - assert ( - account2_sell_id not in taker_order_ids - ), f"Account 2's SELL {account2_sell_id} should be CANCELLED after self-match, not on book" - logger.info("✅ Account 2's SELL cancelled after partial fill (self-match prevention)") - - # Step 6: Verify Account 2's BUY @ 99 is STILL on book (untouched by self-match) - assert ( - account2_buy_id in taker_order_ids - ), f"Account 2's BUY {account2_buy_id} should still be on book (self-match doesn't cancel maker)" - - # Verify the buy order quantity is unchanged - account2_buy_order = next(o for o in open_orders_taker if o.order_id == account2_buy_id) - assert ( - account2_buy_order.qty == fill_qty - ), f"Account 2's BUY qty should be unchanged: expected {fill_qty}, got {account2_buy_order.qty}" - logger.info(f"✅ Account 2's BUY remains on book, qty={account2_buy_order.qty} (untouched)") - - # Cleanup - await taker_tester.client.cancel_order( - order_id=account2_buy_id, symbol=spot_config.symbol, account_id=taker_tester.account_id - ) - await asyncio.sleep(0.05) - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - logger.info("✅ PARTIAL FILL THEN SELF-MATCH TEST COMPLETED") - - -# ============================================================================= -# SECTION 6: Cross-Account Matching (Sanity Checks) -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_cross_account_match_works( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Sanity check: matching DOES work between different accounts. - - Confirms that while self-match is prevented, cross-account matching - works correctly. - """ - # Skip if external liquidity exists - taker would match external orders instead of our maker - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping cross-account match test: external liquidity exists. " - "Taker orders would match external liquidity first." - ) - - logger.info("=" * 80) - logger.info("TEST: Cross-account matching works") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - order_price = spot_config.price(1.04) - - # Maker places GTC sell - maker_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() - maker_order_id = await maker_tester.orders.create_limit(maker_params) - await maker_tester.wait.for_order_creation(maker_order_id) - logger.info(f"✅ Maker sell: {maker_order_id}") - - # Taker sends IOC buy (different account) - taker_params = OrderBuilder.from_config(spot_config).buy().price(str(round(order_price * 1.01, 2))).ioc().build() - taker_order_id = await taker_tester.orders.create_limit(taker_params) - - # Wait for execution (strict matching on order_id and all fields) - expected_order = limit_order_params_to_order(taker_params, taker_tester.account_id) - execution = await taker_tester.wait.for_spot_execution(taker_order_id, expected_order) - logger.info(f"✅ Execution: {execution.order_id}") - - # Verify maker filled - await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Maker filled") - - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() - - -@pytest.mark.spot -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_non_crossing_orders_can_match_other_accounts( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Non-crossing orders from same account can still match with other accounts. - - Account 1 has non-crossing orders on both sides. - Account 2 places an order that crosses Account 1's order. - The cross-account match should succeed. - """ - # Skip if external liquidity exists - taker would match external orders instead of our maker - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping non-crossing orders test: external liquidity exists. " - "Taker orders would match external liquidity first." - ) - - logger.info("=" * 80) - logger.info("TEST: Non-crossing orders can match other accounts") - logger.info("=" * 80) - - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - account1_sell_price = spot_config.price(1.04) - account1_buy_price = spot_config.price(0.97) - _ = spot_config.price(0.96) # account2_sell_price - calculated for reference - - # Account 1 places sell at high price - sell_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() - account1_sell_id = await maker_tester.orders.create_limit(sell_params) - await maker_tester.wait.for_order_creation(account1_sell_id) - logger.info(f"✅ Account 1 sell: ${account1_sell_price:.2f}") - - # Account 1 places buy at low price (non-crossing) - buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() - account1_buy_id = await maker_tester.orders.create_limit(buy_params) - await maker_tester.wait.for_order_creation(account1_buy_id) - logger.info(f"✅ Account 1 buy: ${account1_buy_price:.2f}") - - # Verify both on book - open_orders = await maker_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - assert account1_sell_id in open_order_ids - assert account1_buy_id in open_order_ids - logger.info("✅ Both Account 1 orders on book") - - # Account 2 places sell that crosses Account 1's buy - taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.96).ioc().build() - taker_order_id = await taker_tester.orders.create_limit(taker_params) - - # Wait for execution (strict matching on order_id and all fields) - expected_order = limit_order_params_to_order(taker_params, taker_tester.account_id) - execution = await taker_tester.wait.for_spot_execution(taker_order_id, expected_order) - logger.info(f"✅ Execution: {execution.order_id}") - - # Verify Account 1's buy filled, sell remains - await maker_tester.wait.for_order_state(account1_buy_id, OrderStatus.FILLED, timeout=5) - logger.info("✅ Account 1's buy filled") - - open_orders = await maker_tester.client.get_open_orders() - open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] - assert account1_sell_id in open_order_ids, "Account 1 sell should remain" - logger.info("✅ Account 1's sell remains on book") - - # Cleanup - await maker_tester.client.cancel_order( - order_id=account1_sell_id, symbol=spot_config.symbol, account_id=maker_tester.account_id - ) - await asyncio.sleep(0.05) - await maker_tester.check.no_open_orders() - await taker_tester.check.no_open_orders() diff --git a/tests/validation/__init__.py b/tests/validation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/validation/test_client_guards.py b/tests/validation/test_client_guards.py new file mode 100644 index 00000000..82ea2a8e --- /dev/null +++ b/tests/validation/test_client_guards.py @@ -0,0 +1,349 @@ +# pylint: disable=protected-access,redefined-outer-name +"""Client-side entry guards for cancelAllAfter and modifyOrder. + +Offline (no devnet): builds payloads with a fixed key + a hand-seeded +symbol→marketId map (same seam as tests/parity/test_wire_serialization.py) +and asserts on the client-layer validation rules: + +- ``timeout_ms`` bounds: 0 (disarm) or [5000, 60000]; out-of-range raises + BEFORE a nonce is consumed (a rejected arm must not burn the per-wallet + nonce counter). +- modify targeting: exactly one of ``order_id`` / ``client_order_id`` + (``client_order_id=0`` is not a valid target). +- ``resting_client_order_id`` resolution: the SIGNED ``OrderDetails.clientOrderId`` + is the resting order's id — defaulting to the targeting ``client_order_id``, + overridden by an explicit ``resting_client_order_id``, and 0 under + ``order_id`` targeting (verified by re-signing with the expected values). +- post-only gate-lift: postOnly=True + GTC flows AND is covered by the + signature (the entry rejections — postOnly+IOC, GTT — are pinned in + tests/parity/test_wire_serialization.py). +""" + +from __future__ import annotations + +from typing import Any + +from decimal import Decimal + +import pytest + +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api import ReyaTradingClient +from sdk.reya_rest_api.auth.signatures import OrderTypeInt, TimeInForceInt +from sdk.reya_rest_api.config import TradingConfig +from sdk.reya_rest_api.models.orders import LimitOrderParameters, ModifyOrderParameters + +pytestmark = pytest.mark.offline + +PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +SIGNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +CHAIN_ID = 89346162 +PERP_SYMBOL = "ETHRUSDPERP" + +PINNED_NONCE = 1700000000000005 +PINNED_DEADLINE = 1745000300 + + +@pytest.fixture +def client() -> ReyaTradingClient: + """A ReyaTradingClient that can build payloads offline. + + Seeds the symbol→marketId map directly instead of calling ``start()`` + (which loads market definitions over the network) and pins + ``dex_id_override=2`` so re-signed expectations are env-independent. + """ + config = TradingConfig( + api_url="https://invalid.example", # never called — building is pure + chain_id=CHAIN_ID, + owner_wallet_address=SIGNER_ADDRESS, + private_key=PRIVATE_KEY, + account_id=12345, + dex_id_override=2, + ) + c = ReyaTradingClient(config) + c._symbol_to_market_id = {PERP_SYMBOL: 1} + c._initialized = True + return c + + +def _last_nonce(client: ReyaTradingClient) -> int: + """The wallet's current class-level nonce watermark (0 if untouched).""" + return ReyaTradingClient._wallet_nonces.get(client.owner_wallet_address.lower(), 0) + + +# ============================================================================ +# cancelAllAfter timeout_ms bounds +# ============================================================================ + + +@pytest.mark.cod +@pytest.mark.parametrize("timeout_ms", [1, 4999, 60001]) +def test_cancel_all_after_out_of_range_timeout_rejected_before_nonce( + client: ReyaTradingClient, timeout_ms: int +) -> None: + """timeout_ms outside {0} ∪ [5000, 60000] raises BEFORE a nonce is + consumed — a rejected arm must not advance the per-wallet counter.""" + nonce_before = _last_nonce(client) + with pytest.raises(ValueError, match="timeout_ms must be"): + client.build_cancel_all_after_payload(timeout_ms=timeout_ms) + assert _last_nonce(client) == nonce_before, "rejected cancelAllAfter consumed a nonce" + + +@pytest.mark.cod +@pytest.mark.parametrize("timeout_ms", [0, 5000, 60000]) +def test_cancel_all_after_in_range_timeout_builds(client: ReyaTradingClient, timeout_ms: int) -> None: + """0 (disarm) and the [5000, 60000] bounds inclusive all build a signed payload.""" + payload = client.build_cancel_all_after_payload(timeout_ms=timeout_ms) + assert payload["timeoutMs"] == timeout_ms + assert payload["signature"].startswith("0x") + + +# ============================================================================ +# modifyOrder targeting (exactly one of order_id / client_order_id) +# ============================================================================ + + +def _modify_params(**overrides: Any) -> ModifyOrderParameters: + """A complete, valid post-modify state targeting by order_id. + + The resting order is GTT (its ``expiresAfter`` is strictly after the + deadline), so the non-zero ``expires_after`` here satisfies the + GTC/GTT↔``expiresAfter`` coupling. A modify cannot flip TIF — the caller + restates the resting order's immutable TIF. + """ + fields: dict[str, Any] = { + "symbol": PERP_SYMBOL, + "is_buy": True, + "limit_px": "2950", + "qty": "0.75", + "post_only": True, + "expires_after": 1745003600, + "time_in_force": TimeInForce.GTT, + "order_id": 63552420354981888, + "deadline": PINNED_DEADLINE, + "nonce": PINNED_NONCE, + } + fields.update(overrides) + return ModifyOrderParameters(**fields) + + +@pytest.mark.modify +def test_modify_with_both_identifiers_rejected(client: ReyaTradingClient) -> None: + with pytest.raises(ValueError, match="exactly one of order_id or client_order_id"): + client.build_modify_order_payload(_modify_params(client_order_id=777)) + + +@pytest.mark.modify +def test_modify_with_neither_identifier_rejected(client: ReyaTradingClient) -> None: + with pytest.raises(ValueError, match="exactly one of order_id or client_order_id"): + client.build_modify_order_payload(_modify_params(order_id=None)) + + +@pytest.mark.modify +def test_modify_with_client_order_id_zero_rejected(client: ReyaTradingClient) -> None: + """client_order_id=0 is the unset sentinel on the wire, so it can never + resolve to a resting order — rejected as a target.""" + with pytest.raises(ValueError, match="client_order_id 0 is not a valid modify target"): + client.build_modify_order_payload(_modify_params(order_id=None, client_order_id=0)) + + +# ============================================================================ +# resting_client_order_id resolution into the SIGNED OrderDetails.clientOrderId +# ============================================================================ + + +def _expected_modify_signature(client: ReyaTradingClient, signed_client_order_id: int) -> str: + """Re-sign the _modify_params() post-modify state with an explicit + OrderDetails.clientOrderId; comparing against the builder's signature + pins which clientOrderId actually got signed.""" + return client.signature_generator.sign_order( + account_id=12345, + market_id=1, + exchange_id=2, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal("0.75"), + limit_price=Decimal("2950"), + trigger_price=Decimal("0"), + time_in_force=int(TimeInForceInt.GTT), + client_order_id=signed_client_order_id, + reduce_only=False, + expires_after=1745003600, + nonce=PINNED_NONCE, + deadline=PINNED_DEADLINE, + post_only=True, + ) + + +@pytest.mark.modify +def test_modify_client_order_id_targeting_defaults_signed_client_order_id( + client: ReyaTradingClient, +) -> None: + """Targeting by client_order_id without resting_client_order_id: the signed + clientOrderId defaults to the targeting value (TS SDK + ``restingClientOrderId ?? clientOrderId ?? 0`` parity).""" + payload, _nonce = client.build_modify_order_payload(_modify_params(order_id=None, client_order_id=777)) + assert payload["signature"] == _expected_modify_signature(client, signed_client_order_id=777) + # Sanity: the default actually matters — 0 would sign different bytes. + assert payload["signature"] != _expected_modify_signature(client, signed_client_order_id=0) + + +@pytest.mark.modify +def test_modify_explicit_resting_client_order_id_wins(client: ReyaTradingClient) -> None: + """An explicit resting_client_order_id beats the targeting client_order_id + in the signed OrderDetails.clientOrderId.""" + payload, _nonce = client.build_modify_order_payload( + _modify_params(order_id=None, client_order_id=777, resting_client_order_id=42) + ) + assert payload["signature"] == _expected_modify_signature(client, signed_client_order_id=42) + assert payload["signature"] != _expected_modify_signature(client, signed_client_order_id=777) + + +@pytest.mark.modify +def test_modify_order_id_targeting_signs_client_order_id_zero(client: ReyaTradingClient) -> None: + """Targeting by order_id without resting_client_order_id: the signed + clientOrderId falls back to 0.""" + payload, _nonce = client.build_modify_order_payload(_modify_params()) + assert payload["signature"] == _expected_modify_signature(client, signed_client_order_id=0) + + +# ============================================================================ +# modify TIF<->expiresAfter coupling (against the resting order's immutable TIF) +# ============================================================================ + + +@pytest.mark.modify +def test_modify_gtt_with_future_expiry_builds(client: ReyaTradingClient) -> None: + """A GTT modify whose expiresAfter is strictly after the deadline builds — + the resting GTT stays auto-expiring after the in-place edit.""" + payload, _nonce = client.build_modify_order_payload(_modify_params()) + assert payload["expiresAfter"] == 1745003600 + + +@pytest.mark.modify +def test_modify_gtt_without_expiry_rejected(client: ReyaTradingClient) -> None: + """A GTT modify must keep a non-zero expiresAfter — dropping it would turn a + resting GTT into a never-expiring order (which is GTC). Rejected.""" + with pytest.raises(ValueError, match="GTT orders require a non-zero expires_after"): + client.build_modify_order_payload(_modify_params(expires_after=None)) + + +@pytest.mark.modify +def test_modify_gtt_expiry_not_after_deadline_rejected(client: ReyaTradingClient) -> None: + """A GTT modify whose expiresAfter is not strictly after the deadline is + rejected — the order would expire within its own entry window.""" + with pytest.raises(ValueError, match="GTT expires_after must be greater than deadline"): + client.build_modify_order_payload(_modify_params(expires_after=PINNED_DEADLINE)) + + +@pytest.mark.modify +def test_modify_gtc_with_expiry_rejected(client: ReyaTradingClient) -> None: + """A GTC modify must not carry an expiry — GTC never expires. Pairing it + with a non-zero expiresAfter is the legacy GTC-with-expiry shape (now GTT).""" + with pytest.raises(ValueError, match="GTC orders must not expire"): + client.build_modify_order_payload(_modify_params(time_in_force=TimeInForce.GTC)) + + +@pytest.mark.modify +def test_modify_gtc_without_expiry_builds(client: ReyaTradingClient) -> None: + """A GTC modify with no expiresAfter builds — GTC rests until cancelled. + expiresAfter serializes as the signed never-expires sentinel 0 (matching the + signed OrderDetails; the wire field is a numeric uint, never null).""" + payload, _nonce = client.build_modify_order_payload( + _modify_params(time_in_force=TimeInForce.GTC, expires_after=None) + ) + assert payload["expiresAfter"] == 0 + + +# ============================================================================ +# post-only gate-lift (signed-level extension of the wire-shape tests) +# ============================================================================ + + +@pytest.mark.post_only +def test_post_only_gtc_flows_and_is_signed(client: ReyaTradingClient) -> None: + """post_only=True + GTC flows (gate lifted), the payload carries + postOnly=true, AND the signature covers postOnly=True — re-signing the + same envelope with the returned nonce reproduces the payload's bytes. + The IOC and GTT rejections stay pinned in test_wire_serialization.py.""" + payload, nonce = client.build_create_limit_order_payload( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=True, + limit_px="3000", + qty="0.5", + time_in_force=TimeInForce.GTC, + post_only=True, + client_order_id=42, + deadline=PINNED_DEADLINE, + ) + ) + assert payload["postOnly"] is True + expected = client.signature_generator.sign_order( + account_id=12345, + market_id=1, + exchange_id=2, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal("0.5"), + limit_price=Decimal("3000"), + trigger_price=Decimal("0"), + time_in_force=int(TimeInForceInt.GTC), + client_order_id=42, + reduce_only=False, + expires_after=0, + nonce=nonce, + deadline=PINNED_DEADLINE, + post_only=True, + ) + assert payload["signature"] == expected + + +@pytest.mark.post_only +def test_post_only_gtt_flows_and_is_signed(client: ReyaTradingClient) -> None: + """post_only=True + GTT flows (a GTT rests until it expires, so it can be + maker-only), the payload carries postOnly=true AND a non-zero expiresAfter, + and the signature covers BOTH postOnly=True and timeInForce==GTT(2) — + re-signing the same envelope reproduces the payload's bytes. Falsifiability: + re-signing as GTC (the only other resting TIF) must NOT match, proving the + GTT int is what got signed.""" + expires_after = PINNED_DEADLINE + 600 + payload, nonce = client.build_create_limit_order_payload( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=True, + limit_px="3000", + qty="0.5", + time_in_force=TimeInForce.GTT, + post_only=True, + client_order_id=42, + deadline=PINNED_DEADLINE, + expires_after=expires_after, + ) + ) + assert payload["postOnly"] is True + assert payload["timeInForce"] == TimeInForce.GTT.value + assert payload["expiresAfter"] == expires_after + + def _resign(tif_int: int) -> str: + return client.signature_generator.sign_order( + account_id=12345, + market_id=1, + exchange_id=2, + order_type=int(OrderTypeInt.LIMIT), + is_buy=True, + qty=Decimal("0.5"), + limit_price=Decimal("3000"), + trigger_price=Decimal("0"), + time_in_force=tif_int, + client_order_id=42, + reduce_only=False, + expires_after=expires_after, + nonce=nonce, + deadline=PINNED_DEADLINE, + post_only=True, + ) + + assert payload["signature"] == _resign(int(TimeInForceInt.GTT)) + # Sanity: the GTT int matters — re-signing as GTC signs different bytes. + assert payload["signature"] != _resign(int(TimeInForceInt.GTC)) diff --git a/tests/ws_exec/test_ws_exec.py b/tests/ws_exec/test_ws_exec.py index 58d2228a..8acfff6e 100644 --- a/tests/ws_exec/test_ws_exec.py +++ b/tests/ws_exec/test_ws_exec.py @@ -44,7 +44,6 @@ import json import os -import ssl import time import uuid from dataclasses import dataclass @@ -53,7 +52,6 @@ import pytest import pytest_asyncio from dotenv import load_dotenv -from websocket import WebSocket, create_connection # type: ignore[attr-defined] # pylint: disable=no-name-in-module from sdk.open_api.models import TimeInForce from sdk.open_api.models.order_type import OrderType @@ -61,6 +59,13 @@ from sdk.reya_rest_api.config import TradingConfig from sdk.reya_rest_api.models.orders import LimitOrderParameters, TriggerOrderParameters from sdk.reya_ws_exec import ReyaWsExecClient +from tests.helpers.ws_exec_harness import ( + assert_per_op_error, + assert_top_level_error, + raw_connect, + raw_recv_until, + raw_send_envelope, +) load_dotenv() @@ -296,68 +301,6 @@ async def flow_perp_ioc_close(client: ReyaWsExecClient, qty: Decimal) -> None: # connection, do the one negative probe, and close. -def _raw_connect(url: str) -> WebSocket: - sslopt = {"cert_reqs": ssl.CERT_REQUIRED} - return create_connection(url, sslopt=sslopt) - - -def _raw_send_envelope(ws: WebSocket, msg_type: str, env_id: str, payload: dict) -> None: - # Drop None-valued fields so a raw frame matches what the high-level OpenAPI - # client puts on the wire (it serializes with exclude_none). Notably the - # ws-exec server rejects a *present* null `reduceOnly` on spot with - # INPUT_VALIDATION ("reduceOnly field is not supported for spot markets"). - clean = {k: v for k, v in payload.items() if v is not None} - ws.send(json.dumps({"type": msg_type, "id": env_id, "payload": clean})) - - -def _raw_recv_until( - ws: WebSocket, - predicate, - timeout_s: float = RECV_TIMEOUT_S, -) -> dict: - """Read frames until ``predicate(frame)`` returns True. Times out cleanly.""" - deadline = time.time() + timeout_s - while time.time() < deadline: - ws.settimeout(max(0.1, deadline - time.time())) - try: - raw = ws.recv() - except Exception as exc: # noqa: BLE001 — surface as RuntimeError below - raise RuntimeError(f"raw recv failed: {exc}") from exc - if not raw: - continue - frame: dict = json.loads(raw) - if frame.get("type") == "ping": - pong: dict = {"type": "pong"} - if frame.get("id") is not None: - pong["id"] = frame["id"] - ws.send(json.dumps(pong)) - continue - if predicate(frame): - return frame - raise RuntimeError("raw recv timed out before predicate matched") - - -def _assert_top_level_error(frame: dict, expected_code: str, op_label: str) -> None: - if frame.get("type") != "error": - raise RuntimeError(f"[{op_label}] expected type=error, got {frame!r}") - err = frame.get("error", {}) or {} - actual = err.get("error") - if actual != expected_code: - raise RuntimeError(f"[{op_label}] expected {expected_code!r}, got {actual!r} message={err.get('message')!r}") - - -def _assert_per_op_error(frame: dict, expected_codes: tuple[str, ...], op_label: str) -> dict: - if frame.get("ok"): - raise RuntimeError(f"[{op_label}] expected error, got ok=true payload={frame.get('payload')!r}") - err = frame.get("error", {}) or {} - actual = err.get("error") - if actual not in expected_codes: - raise RuntimeError( - f"[{op_label}] expected one of {expected_codes!r}, got error={actual!r} message={err.get('message')!r}" - ) - return err - - async def flow_err_duplicate_request_id( ws_url: str, rest_client: ReyaTradingClient, @@ -387,17 +330,17 @@ async def flow_err_duplicate_request_id( ) ) - ws = _raw_connect(ws_url) + ws = raw_connect(ws_url) try: - _raw_send_envelope(ws, "createOrder", shared_env_id, payload_a) - _raw_send_envelope(ws, "createOrder", shared_env_id, payload_b) + raw_send_envelope(ws, "createOrder", shared_env_id, payload_a) + raw_send_envelope(ws, "createOrder", shared_env_id, payload_b) - err_frame = _raw_recv_until(ws, lambda f: f.get("type") == "error") - _assert_top_level_error(err_frame, "DUPLICATE_REQUEST_ID", "duplicate request id") + err_frame = raw_recv_until(ws, lambda f: f.get("type") == "error") + assert_top_level_error(err_frame, "DUPLICATE_REQUEST_ID", "duplicate request id") print(" [err] DUPLICATE_REQUEST_ID OK") # The first send's response still comes back ok=true under shared_env_id. - ok_frame = _raw_recv_until(ws, lambda f: f.get("id") == shared_env_id and "ok" in f) + ok_frame = raw_recv_until(ws, lambda f: f.get("id") == shared_env_id and "ok" in f) leaked_order_id = (ok_frame.get("payload") or {}).get("orderId") finally: ws.close() @@ -415,11 +358,11 @@ async def flow_err_duplicate_request_id( def flow_err_malformed_json(ws_url: str) -> None: """Send a non-JSON string. Server returns MALFORMED_JSON at the framing layer.""" - ws = _raw_connect(ws_url) + ws = raw_connect(ws_url) try: ws.send("this-is-not-json") - err_frame = _raw_recv_until(ws, lambda f: f.get("type") == "error") - _assert_top_level_error(err_frame, "MALFORMED_JSON", "malformed json") + err_frame = raw_recv_until(ws, lambda f: f.get("type") == "error") + assert_top_level_error(err_frame, "MALFORMED_JSON", "malformed json") print(" [err] MALFORMED_JSON OK") finally: ws.close() @@ -427,12 +370,12 @@ def flow_err_malformed_json(ws_url: str) -> None: def flow_err_unknown_type(ws_url: str) -> None: """Send a frame with an unknown `type`. Server returns UNKNOWN_TYPE.""" - ws = _raw_connect(ws_url) + ws = raw_connect(ws_url) try: env_id = uuid.uuid4().hex[:12] ws.send(json.dumps({"type": "foobar", "id": env_id, "payload": {}})) - err_frame = _raw_recv_until(ws, lambda f: f.get("type") == "error") - _assert_top_level_error(err_frame, "UNKNOWN_TYPE", "unknown type") + err_frame = raw_recv_until(ws, lambda f: f.get("type") == "error") + assert_top_level_error(err_frame, "UNKNOWN_TYPE", "unknown type") print(" [err] UNKNOWN_TYPE OK") finally: ws.close() @@ -456,19 +399,19 @@ async def flow_err_invalid_nonce( ) leaked_order_id: str | None = None - ws = _raw_connect(ws_url) + ws = raw_connect(ws_url) try: env_id_1 = uuid.uuid4().hex[:12] - _raw_send_envelope(ws, "createOrder", env_id_1, payload) - ok = _raw_recv_until(ws, lambda f: f.get("id") == env_id_1 and "ok" in f) + raw_send_envelope(ws, "createOrder", env_id_1, payload) + ok = raw_recv_until(ws, lambda f: f.get("id") == env_id_1 and "ok" in f) if not ok.get("ok"): raise RuntimeError(f"invalid-nonce setup failed: {ok!r}") leaked_order_id = (ok.get("payload") or {}).get("orderId") env_id_2 = uuid.uuid4().hex[:12] - _raw_send_envelope(ws, "createOrder", env_id_2, payload) - err = _raw_recv_until(ws, lambda f: f.get("id") == env_id_2 and "ok" in f) - _assert_per_op_error(err, ("INVALID_NONCE_ERROR",), "invalid nonce replay") + raw_send_envelope(ws, "createOrder", env_id_2, payload) + err = raw_recv_until(ws, lambda f: f.get("id") == env_id_2 and "ok" in f) + assert_per_op_error(err, ("INVALID_NONCE_ERROR",), "invalid nonce replay") print(" [err] INVALID_NONCE_ERROR OK") finally: ws.close() @@ -501,12 +444,12 @@ def flow_err_order_deadline_passed(ws_url: str, rest_client: ReyaTradingClient, ) ) - ws = _raw_connect(ws_url) + ws = raw_connect(ws_url) try: env_id = uuid.uuid4().hex[:12] - _raw_send_envelope(ws, "createOrder", env_id, payload) - resp = _raw_recv_until(ws, lambda f: f.get("id") == env_id and "ok" in f) - err = _assert_per_op_error( + raw_send_envelope(ws, "createOrder", env_id, payload) + resp = raw_recv_until(ws, lambda f: f.get("id") == env_id and "ok" in f) + err = assert_per_op_error( resp, ("ORDER_DEADLINE_PASSED_ERROR", "INPUT_VALIDATION_ERROR"), "expired deadline", @@ -540,12 +483,12 @@ def flow_err_unauthorized_signature( # Declared as the OTHER wallet -> recovered signer vs declared mismatch. payload["signerWallet"] = other_rest_client.signer_wallet_address - ws = _raw_connect(ws_url) + ws = raw_connect(ws_url) try: env_id = uuid.uuid4().hex[:12] - _raw_send_envelope(ws, "createOrder", env_id, payload) - resp = _raw_recv_until(ws, lambda f: f.get("id") == env_id and "ok" in f) - err = _assert_per_op_error( + raw_send_envelope(ws, "createOrder", env_id, payload) + resp = raw_recv_until(ws, lambda f: f.get("id") == env_id and "ok" in f) + err = assert_per_op_error( resp, ("UNAUTHORIZED_SIGNATURE_ERROR", "CREATE_ORDER_OTHER_ERROR"), "signer mismatch", @@ -633,7 +576,7 @@ async def harness(): try: spot_markets = {m.symbol: m for m in await spot_rest.reference.get_spot_market_definitions()} - perp_markets = {m.symbol: m for m in await perp_rest.reference.get_market_definitions()} + perp_markets = {m.symbol: m for m in await perp_rest.reference.get_perp_market_definitions()} if SPOT_SYMBOL not in spot_markets: raise RuntimeError(f"{SPOT_SYMBOL} not found in /spotMarketDefinitions") if PERP_SYMBOL not in perp_markets: