diff --git a/.env.example b/.env.example index dd7e356e..d0b9452d 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,25 @@ -### Cronos (testnet) +### Devnet1 (perpOB testnet) CHAIN_ID=89346162 -REYA_WS_URL="wss://websocket-testnet.reya.xyz/" -REYA_WS_EXEC_URL="wss://ws-exec-testnet.reya.xyz" -REYA_API_URL="https://api-cronos.reya.xyz/v2" +REYA_WS_URL="wss://websocket-devnet.reya-cronos.network/" +REYA_WS_EXEC_URL="wss://ws-exec-devnet.reya-cronos.network" +REYA_API_URL="https://api-devnet.reya-cronos.network/v2" +# OrdersGateway proxy = the EIP-712 verifyingContract. +REYA_ORDERS_GATEWAY="0x7Ec89E555c771D2B5939aBE5C4E4291852633D4D" + +### Cronos testnet (same chain id as devnet1, different deployment) +#CHAIN_ID=89346162 +#REYA_WS_URL="wss://websocket-testnet.reya.xyz/" +#REYA_WS_EXEC_URL="wss://ws-exec-testnet.reya.xyz" +#REYA_API_URL="https://api-cronos.reya.xyz/v2" +# Cronos shares devnet1's chain id, so set the gateway explicitly to target it: +#REYA_ORDERS_GATEWAY="0x5a0ac2f89e0bdeafc5c549e354842210a3e87ca5" ### Reya Network (mainnet) #CHAIN_ID=1729 #REYA_WS_URL="wss://ws.reya.xyz/" #REYA_WS_EXEC_URL="wss://ws-exec.reya.xyz" #REYA_API_URL="https://api.reya.xyz/v2" +#REYA_ORDERS_GATEWAY="0xfc8c96be87da63cecddbf54abfa7b13ee8044739" ### Staging (uses mainnet chain ID 1729) #CHAIN_ID=1729 @@ -16,17 +27,22 @@ REYA_API_URL="https://api-cronos.reya.xyz/v2" #REYA_WS_EXEC_URL="wss://ws-exec-staging.reya.xyz" #REYA_API_URL="https://api-staging.reya.xyz/v2" -# PERP_ACCOUNT_ID_1 +# PERP_ACCOUNT_ID_1 (default single-account perp tester) PERP_ACCOUNT_ID_1= PERP_PRIVATE_KEY_1= PERP_WALLET_ADDRESS_1= -# SPOT_ACCOUNT_ID_1 +# PERP_ACCOUNT_ID_2 (used as the perp taker in maker/taker tests; perp orderbook needs both sides) +PERP_ACCOUNT_ID_2= +PERP_PRIVATE_KEY_2= +PERP_WALLET_ADDRESS_2= + +# SPOT_ACCOUNT_ID_1 (spot maker) SPOT_ACCOUNT_ID_1= SPOT_PRIVATE_KEY_1= SPOT_WALLET_ADDRESS_1= -# SPOT_ACCOUNT_ID_2 +# SPOT_ACCOUNT_ID_2 (spot taker) SPOT_ACCOUNT_ID_2= SPOT_PRIVATE_KEY_2= SPOT_WALLET_ADDRESS_2= diff --git a/.gitignore b/.gitignore index 586a67e8..30c98724 100644 --- a/.gitignore +++ b/.gitignore @@ -627,3 +627,5 @@ testing.py # End of https://www.gitignore.io/api/osx,python,pycharm,windows,visualstudio,visualstudiocode examples/config.json + +.claude/ diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index 292b7147..07d5b791 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -22,6 +22,8 @@ sdk/open_api/models/create_order_request.py sdk/open_api/models/create_order_response.py sdk/open_api/models/depth.py sdk/open_api/models/depth_type.py +sdk/open_api/models/execution_bust.py +sdk/open_api/models/execution_bust_list.py sdk/open_api/models/execution_type.py sdk/open_api/models/fee_tier_parameters.py sdk/open_api/models/global_fee_parameters.py @@ -45,8 +47,6 @@ sdk/open_api/models/server_error.py sdk/open_api/models/server_error_code.py sdk/open_api/models/side.py sdk/open_api/models/spot_execution.py -sdk/open_api/models/spot_execution_bust.py -sdk/open_api/models/spot_execution_bust_list.py sdk/open_api/models/spot_execution_list.py sdk/open_api/models/spot_market_definition.py sdk/open_api/models/spot_market_summary.py diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..e27a2280 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,152 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Important Notes +* Always read entire files. Otherwise, you don’t know what you don’t know, and will end up making mistakes, duplicating code that already exists, or misunderstanding the architecture. +* Commit early and often. When working on large tasks, your task could be broken down into multiple logical milestones. After a certain milestone is completed and confirmed to be ok by the user, you should commit it. If you do not, if something goes wrong in further steps, we would need to end up throwing away all the code, which is expensive and time consuming. +* Your internal knowledgebase of libraries might not be up to date. When working with any external library, unless you are 100% sure that the library has a super stable interface, you will look up the latest syntax and usage via either Perplexity (first preference) or web search (less preferred, only use if Perplexity is not available) +* Do not say things like: “x library isn’t working so I will skip it”. Generally, it isn’t working because you are using the incorrect syntax or patterns. This applies doubly when the user has explicitly asked you to use a specific library, if the user wanted to use another library they wouldn’t have asked you to use a specific one in the first place. +* Always run linting after making major changes. Otherwise, you won’t know if you’ve corrupted a file or made syntax errors, or are using the wrong methods, or using methods in the wrong way. +* Please organise code into separate files wherever appropriate, and follow general coding best practices about variable naming, modularity, function complexity, file sizes, commenting, etc. +* Code is read more often than it is written, make sure your code is always optimised for readability +* Unless explicitly asked otherwise, the user never wants you to do a “dummy” implementation of any given task. Never do an implementation where you tell the user: “This is how it *would* look like”. Just implement the thing. +* Whenever you are starting a new task, it is of utmost importance that you have clarity about the task. You should ask the user follow up questions if you do not, rather than making incorrect assumptions. +* Do not carry out large refactors unless explicitly instructed to do so. +* When starting on a new task, you should first understand the current architecture, identify the files you will need to modify, and come up with a Plan. In the Plan, you will think through architectural aspects related to the changes you will be making, consider edge cases, and identify the best approach for the given task. Get your Plan approved by the user before writing a single line of code. +* If you are running into repeated issues with a given task, figure out the root cause instead of throwing random things at the wall and seeing what sticks, or throwing in the towel by saying “I’ll just use another library / do a dummy implementation”. +* You are an incredibly talented and experienced polyglot with decades of experience in diverse areas such as software architecture, system design, development, UI & UX, copywriting, and more. +* When doing UI & UX work, make sure your designs are both aesthetically pleasing, easy to use, and follow UI / UX best practices. You pay attention to interaction patterns, micro-interactions, and are proactive about creating smooth, engaging user interfaces that delight users. +* When you receive a task that is very large in scope or too vague, you will first try to break it down into smaller subtasks. If that feels difficult or still leaves you with too many open questions, push back to the user and ask them to consider breaking down the task for you, or guide them through that process. This is important because the larger the task, the more likely it is that things go wrong, wasting time and energy for everyone involved. + + +## Project Overview + +This is the Reya Python SDK, providing Python interfaces for interacting with the Reya ecosystem. The SDK consists of three main components: + +- **REST API Client** (`sdk/reya_rest_api/`) - HTTP client for Reya's Trading API +- **RPC Client** (`sdk/reya_rpc/`) - Web3-based client for on-chain actions +- **WebSocket Client** (`sdk/reya_websocket/`) - Real-time data streaming client + +## Development Commands + +### Setup and Installation +```bash +# Install dependencies +poetry install + +# Activate virtual environment +poetry shell +# Or use: source $(poetry env info --path)/bin/activate +``` + +### Code Quality and Testing +```bash +# Run all linting and formatting (via pre-commit) +make lint +# Or: make pre-commit + +# Run specific linter only +make pre-commit hook=black +make pre-commit hook=isort +make pre-commit hook=flake8 +make pre-commit hook=mypy + +# Install additional type stubs for mypy +make install-types + +# Run security checks +make check-safety + +# Clean build artifacts +make cleanup +``` + +### Dependency Management +```bash +# Update poetry.lock +make lockfile-update + +# Fully regenerate poetry.lock +make lockfile-update-full + +# Update dev dependencies to latest +make update-dev-deps +``` + +### Running Examples +```bash +# Activate environment first +poetry shell + +# Run examples using module notation +python -m examples.rest_api.wallet_example +python -m examples.websocket.market_monitoring +python -m examples.rpc.trade_execution +``` + +## Architecture Overview + +### REST API Client Architecture +The REST API client (`sdk/reya_rest_api/`) follows a resource-based pattern: + +- **Client** (`client.py`) - Main entry point with signature authentication +- **Resources** (`resources/`) - Organized by API endpoints (wallet, markets, orders, assets, prices) +- **Auth** (`auth/signatures.py`) - EIP-712 signature generation for authenticated requests +- **Models** (`models/`) - Pydantic models for request/response data +- **Config** (`config.py`) - Configuration management with environment variable support + +### RPC Client Architecture +The RPC client (`sdk/reya_rpc/`) provides Web3-based blockchain interactions: + +- **Actions** (`actions/`) - High-level transaction builders for common operations +- **ABIs** (`abis/`) - Smart contract ABIs for all supported contracts +- **Config** (`config.py`) - Network-specific contract addresses and configuration +- **Utils** (`utils/`) - Core transaction execution utilities + +### WebSocket Client Architecture +The WebSocket client (`sdk/reya_websocket/`) offers resource-oriented real-time data access: + +- **Socket** (`socket.py`) - Main WebSocket connection manager with auto-reconnection +- **Resources** (`resources/`) - Market, wallet, and price subscription managers +- **Config** (`config.py`) - WebSocket connection configuration + +### Key Configuration Patterns + +The codebase uses environment variables extensively for configuration: + +- **Trading API**: Uses `TradingConfig.from_env()` to load API URLs, authentication, etc. +- **RPC**: Uses `get_config()` to load chain IDs, contract addresses, private keys +- **WebSocket**: Uses `WebSocketConfig.from_env()` for connection parameters + +### Smart Contract Integration + +The RPC client supports two main networks: +- **Mainnet** (chain_id=1729): Production Reya network +- **Testnet** (chain_id=89346162): Testing environment + +Contract addresses are network-specific and configured in `sdk/reya_rpc/config.py`. + +### Code Quality Configuration + +- **Line length**: 120 characters (Black, isort, Pylint) +- **Python version**: 3.12+ required, 3.10 for type checking +- **Type checking**: Strict mypy configuration with comprehensive checks +- **Pre-commit hooks**: Automated formatting and linting on commit + +## Environment Setup Requirements + +Create `.env` file with: +``` +ACCOUNT_ID=your_account_id +PRIVATE_KEY=your_private_key +CHAIN_ID=1729 # or 89346162 for testnet +REYA_WS_URL=wss://ws.reya.xyz/ +``` + +## Testing Approach + +The project uses pytest with additional packages: +- `pytest-recording` and `vcrpy` for HTTP request/response recording +- `pytest-cov` for coverage reporting +- Test files should be placed alongside source code or in dedicated test directories diff --git a/CLAUDE.md b/CLAUDE.md index 7ba3a781..582a7141 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,6 +39,11 @@ python -m examples.websocket.market_monitoring python -m examples.rpc.trade_execution ``` +## 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. +* **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. + ## Key Architecture - REST: client.py (main entry) -> resources/ (endpoints) -> auth/signatures.py (EIP-712) -> models/ (Pydantic) - RPC: actions/ (tx builders) -> abis/ (contract ABIs) -> config.py (network addresses) diff --git a/README.md b/README.md index 40afb985..997fcbd6 100644 --- a/README.md +++ b/README.md @@ -165,8 +165,8 @@ Create a `.env` file in the project root with the following variables: ACCOUNT_ID=your_account_id PRIVATE_KEY=your_private_key CHAIN_ID=1729 # Use 89346162 for testnet -REYA_WS_URL=wss://ws.reya.xyz/ # Use wss://websocket-testnet.reya.xyz/ for testnet -REYA_API_BASE_URL=https://api.reya.xyz/v2 # Use https://api-cronos.reya.xyz/v2 for testnet +REYA_WS_URL=wss://ws.reya.xyz/ # Use wss://websocket-devnet.reya-cronos.network/ for devnet1 (perpOB testnet) +REYA_API_BASE_URL=https://api.reya.xyz/v2 # Use https://api-devnet.reya-cronos.network/v2 for devnet1 OWNER_WALLET_ADDRESS=your_wallet_address # Required: wallet address for data queries ``` diff --git a/examples/rest_api/perps/order_entry.py b/examples/rest_api/perps/order_entry.py index d7a23e09..a5eec312 100644 --- a/examples/rest_api/perps/order_entry.py +++ b/examples/rest_api/perps/order_entry.py @@ -143,8 +143,9 @@ async def run_stop_loss_orders_test(client: ReyaTradingClient): TriggerOrderParameters( symbol="ETHRUSDPERP", is_buy=False, + qty="0.01", trigger_px="1000", - trigger_type=OrderType.SL, + trigger_type=OrderType.STOP_LOSS, ) ) long_sl_response = handle_order_response("Stop Loss (Long Position)", response) @@ -155,8 +156,9 @@ async def run_stop_loss_orders_test(client: ReyaTradingClient): TriggerOrderParameters( symbol="ETHRUSDPERP", is_buy=True, + qty="0.01", trigger_px="9000", - trigger_type=OrderType.SL, + trigger_type=OrderType.STOP_LOSS, ) ) short_sl_response = handle_order_response("Stop Loss (Short Position)", response) @@ -174,8 +176,9 @@ async def run_take_profit_orders_test(client: ReyaTradingClient): TriggerOrderParameters( symbol="ETHRUSDPERP", is_buy=False, + qty="0.01", trigger_px="10000", - trigger_type=OrderType.TP, + trigger_type=OrderType.TAKE_PROFIT, ) ) long_tp_response = handle_order_response("Take Profit (Long Position)", response) @@ -186,8 +189,9 @@ async def run_take_profit_orders_test(client: ReyaTradingClient): TriggerOrderParameters( symbol="ETHRUSDPERP", is_buy=True, + qty="0.01", trigger_px="1500", - trigger_type=OrderType.TP, + trigger_type=OrderType.TAKE_PROFIT, ) ) short_tp_response = handle_order_response("Take Profit (Short Position)", response) @@ -209,7 +213,11 @@ async def run_order_cancellation_test(client: ReyaTradingClient, order_ids: list order_id = valid_order_ids[0] logger.info(f"Attempting to cancel order: {order_id}") - response = await client.cancel_order(order_id=order_id) + response = await client.cancel_order( + symbol="ETHRUSDPERP", + account_id=client.config.account_id, + order_id=order_id, + ) handle_order_response("Order Cancellation", response) diff --git a/examples/rest_api/spot/cancel_order_by_id.py b/examples/rest_api/spot/cancel_order_by_id.py index 658c0811..9c15304c 100644 --- a/examples/rest_api/spot/cancel_order_by_id.py +++ b/examples/rest_api/spot/cancel_order_by_id.py @@ -49,7 +49,7 @@ async def main() -> None: wallet_address = wallet.address # Determine API URL based on chain - api_url = "https://api.reya.xyz/v2" if chain_id == MAINNET_CHAIN_ID else "https://api-cronos.reya.xyz/v2" + api_url = "https://api.reya.xyz/v2" if chain_id == MAINNET_CHAIN_ID else "https://api-devnet.reya-cronos.network/v2" print("=" * 60) print("CANCEL ORDER BY ID") diff --git a/examples/rest_api/spot/spot_trade.py b/examples/rest_api/spot/spot_trade.py index b581782e..44b23f3e 100644 --- a/examples/rest_api/spot/spot_trade.py +++ b/examples/rest_api/spot/spot_trade.py @@ -18,6 +18,8 @@ python -m examples.rest_api.spot.spot_trade """ +from __future__ import annotations + import asyncio import logging import sys diff --git a/examples/rest_api/spot/spot_transfer.py b/examples/rest_api/spot/spot_transfer.py index 29c80076..8a190668 100644 --- a/examples/rest_api/spot/spot_transfer.py +++ b/examples/rest_api/spot/spot_transfer.py @@ -23,6 +23,8 @@ --qty 5 """ +from __future__ import annotations + import argparse import asyncio import logging diff --git a/examples/websocket/perps/depth_market_maker.py b/examples/websocket/perps/depth_market_maker.py new file mode 100644 index 00000000..270063c8 --- /dev/null +++ b/examples/websocket/perps/depth_market_maker.py @@ -0,0 +1,952 @@ +#!/usr/bin/env python3 +""" +Perp Market Maker (WebSocket Version) - Maintains realistic depth around current ETH price. + +Port of ``examples/websocket/spot/depth_market_maker.py`` for perp markets. +Same architecture: REST bootstrap → WebSocket-driven adjustments → mass cancel on +shutdown. Adaptations vs the spot bot: + +- Config: ``TradingConfig.from_env()`` (PERP_* env vars), not ``from_env_spot``. +- Market def: ``/marketDefinitions`` (no base/quote token split — perps settle in + rUSD; max_leverage drives margin sizing instead of token balances). +- Balance: tracks rUSD collateral only. "Available budget" is a fraction of + rUSD; per-order required margin is approximated as ``price * qty / + max_leverage``. Conservative on purpose — this is a depth source, not an + alpha generator. +- WS executions: ``ws.wallet.perp_executions(wallet)`` instead of + ``spot_executions``. + +Primary motivation: devnet1 has no resident MM, so the perp orderbook for +ETHRUSDPERP is empty almost all the time. The candle task only emits when +both bid and ask are present (no mark-price fallback by design — see +``packages/api/src/tasks/oracle-updates/candles.task.ts:43`` in the off-chain +monorepo), so no candles get written, and the SDK suite's ``test_candles`` +returns empty arrays. This bot maintains a thin two-sided depth ladder so +the candle service has continuous mid prices to record. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_ACCOUNT_ID_1: Your Reya PERP account ID +- PERP_PRIVATE_KEY_1: Your Ethereum private key +- PERP_WALLET_ADDRESS_1: Your wallet address + +Usage: + python -m examples.websocket.perps.depth_market_maker + +Press Ctrl+C to stop (will mass-cancel all orders on exit). +""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +import random +import threading +import time +from dataclasses import dataclass, field +from decimal import ROUND_DOWN, Decimal + +from dotenv import load_dotenv # pip install python-dotenv + +from sdk.async_api.account_balance_update_payload import AccountBalanceUpdatePayload +from sdk.async_api.order_change_update_payload import OrderChangeUpdatePayload +from sdk.async_api.price_update_payload import PriceUpdatePayload +from sdk.async_api.subscribed_message_payload import SubscribedMessagePayload +from sdk.async_api.wallet_perp_execution_update_payload import WalletPerpExecutionUpdatePayload +from sdk.open_api.exceptions import ApiException +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_websocket import ReyaSocket, WebSocketMessage + +# Exceptions worth swallowing inside the MM loop. We want the bot to stay +# alive on transient REST hiccups (network blips → OSError) and SDK-side +# 4xx/5xx responses (ApiException + subclasses like BadRequestException) — +# the most common one in perp MM is "Order not found" when our cancel +# races a fill or expiry. Anything outside this set should propagate so we +# notice real bugs. +RECOVERABLE_EXC: tuple = (OSError, RuntimeError, ApiException) + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger("perp_market_maker_ws") + +# Market configuration (defaults, can be overridden via command line). +# Defaults assume the standard devnet1 / cronos / mainnet ETH perp listing. +# Default to the perp symbol as its own oracle reference. /v2/prices/{symbol} +# returns `oraclePrice` for perp symbols on every env, whereas spot oracle +# pairs (e.g. "ETHRUSD") are not registered on perp-only envs like devnet1. +# Override with --oracle-symbol if you want to peg to a different reference. +DEFAULT_SYMBOL = "ETHRUSDPERP" +DEFAULT_ORACLE_SYMBOL = "ETHRUSDPERP" +DEFAULT_MAX_SPREAD_PCT = Decimal("0.01") # ±1% from reference price +NUM_LEVELS = 5 # bids/asks per side — kept low for a low-volume devnet env +REFRESH_INTERVAL = 5 # seconds between quote adjustments +STATE_REFRESH_CYCLES = 30 # refresh state from REST every N cycles +MIN_COLLATERAL = Decimal("100") # halt MM if rUSD collateral falls below this + +# Fraction of rUSD collateral budgeted across all open orders. The remainder +# is reserve for unexpected fills + margin drift. 0.30 is conservative; bump +# higher if the book stays thin even with collateral headroom. +COLLATERAL_BUDGET_FRACTION = Decimal("0.30") + +# Per-order qty cap (small on purpose — the goal is presence, not size). +MAX_ORDER_QTY = Decimal("0.01") + +# 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 + + +@dataclass +class OpenOrder: + """Represents an open order with its key attributes.""" + + order_id: str + price: Decimal + qty: Decimal + is_buy: bool + + +@dataclass +class MarketParams: + """Subset of perp ``/marketDefinitions`` we actually use.""" + + symbol: str + tick_size: Decimal + min_order_qty: Decimal + qty_step_size: Decimal + # Max leverage drives per-order margin sizing — see + # ``required_margin`` below for the formula. + max_leverage: int + + +@dataclass +class MarketMakerState: + """Thread-safe state container for the market maker.""" + + symbol: str = DEFAULT_SYMBOL + oracle_symbol: str = DEFAULT_ORACLE_SYMBOL + max_spread_pct: Decimal = DEFAULT_MAX_SPREAD_PCT + + market_params: MarketParams | None = None + account_id: int | None = None + wallet_address: str | None = None + + # Dynamic state (updated via WebSocket) + reference_price: Decimal = Decimal("0") + collateral_balance: Decimal = Decimal("0") # rUSD + open_orders: dict[str, OpenOrder] = field(default_factory=dict) + + _lock: threading.Lock = field(default_factory=threading.Lock) + + def update_price(self, price: Decimal) -> None: + with self._lock: + old = self.reference_price + self.reference_price = price + if old != price: + logger.debug(f"📊 Price updated: ${old} → ${price}") + + def update_collateral(self, balance: Decimal) -> None: + with self._lock: + old = self.collateral_balance + self.collateral_balance = balance + if old != balance: + logger.info(f"💰 {COLLATERAL_ASSET} collateral: {old} → {balance}") + + def update_order( + self, order_id: str, status: str, price: Decimal, qty: Decimal, cum_qty: Decimal, is_buy: bool + ) -> None: + with self._lock: + remaining_qty = qty - cum_qty + if status in ("FILLED", "CANCELLED", "REJECTED", "EXPIRED"): + if order_id in self.open_orders: + del self.open_orders[order_id] + logger.debug(f"📋 Order {order_id} removed (status: {status})") + else: + self.open_orders[order_id] = OpenOrder(order_id=order_id, price=price, qty=remaining_qty, is_buy=is_buy) + logger.debug(f"📋 Order {order_id} updated: {status}, remaining={remaining_qty}") + + def log_execution(self, order_id: str, qty: str, price: str, side: str) -> None: + side_str = "BOUGHT" if side == "B" else "SOLD" + logger.info(f"🔔 FILL: {side_str} {qty} @ ${price} (order {order_id})") + + def remove_order(self, order_id: str) -> None: + """Drop an order from local state (used when cancel races a fill/expiry).""" + with self._lock: + if order_id in self.open_orders: + del self.open_orders[order_id] + + def sync_orders(self, fresh_orders: dict[str, OpenOrder]) -> None: + with self._lock: + old_count = len(self.open_orders) + self.open_orders = fresh_orders + if old_count != len(fresh_orders): + logger.info(f"🔄 State synced: {old_count} → {len(fresh_orders)} orders") + + def get_snapshot(self) -> tuple[Decimal, Decimal, list[OpenOrder], list[OpenOrder]]: + """Atomic snapshot of current state for the adjustment loop.""" + with self._lock: + bids = sorted((o for o in self.open_orders.values() if o.is_buy), key=lambda o: o.price, reverse=True) + asks = sorted((o for o in self.open_orders.values() if not o.is_buy), key=lambda o: o.price) + return self.reference_price, self.collateral_balance, bids, asks + + +# --------------------------------------------------------------------------- +# Pricing + sizing helpers +# --------------------------------------------------------------------------- + + +def round_to_tick(price: Decimal, tick_size: Decimal) -> Decimal: + return (price / tick_size).quantize(Decimal("1"), rounding=ROUND_DOWN) * tick_size + + +def round_to_qty_step(qty: Decimal, qty_step_size: Decimal) -> Decimal: + return (qty / qty_step_size).quantize(Decimal("1"), rounding=ROUND_DOWN) * qty_step_size + + +def required_margin(price: Decimal, qty: Decimal, max_leverage: int) -> Decimal: + """Conservative IM estimate. Real margin includes funding accrual, OI cap, + and risk-matrix terms — but for a thin always-on quote on a single market + on devnet1, ``notional / max_leverage`` is a safe overestimate that keeps + us well clear of the protocol's IM check.""" + if max_leverage <= 0: + return price * qty # degenerate; full notional reserve + return (price * qty) / Decimal(max_leverage) + + +def affordable_qty(price: Decimal, available_margin: Decimal, market_params: MarketParams) -> Decimal: + """Largest qty within MAX_ORDER_QTY that fits in ``available_margin``.""" + if available_margin <= 0 or price <= 0: + return Decimal("0") + max_qty_by_margin = available_margin * Decimal(market_params.max_leverage) / price + qty = min(MAX_ORDER_QTY, max_qty_by_margin) + return round_to_qty_step(qty, market_params.qty_step_size) + + +def generate_random_qty(min_qty: Decimal, max_qty: Decimal, qty_step_size: Decimal) -> str: + """Random qty in [min, max], step-aligned. Spot bot uses this same shape; + keeping it for parity so ladder behaviour is symmetric.""" + if max_qty <= min_qty: + return str(min_qty) + qty_range = max_qty - min_qty + random_offset = qty_range * Decimal(random.uniform(0.0, 1.0)) # nosec B311 + qty = round_to_qty_step(min_qty + random_offset, qty_step_size) + return str(max(qty, min_qty)) + + +def generate_quote_prices( + reference: Decimal, max_deviation_pct: Decimal, num_levels: int, tick_size: Decimal +) -> tuple[list[str], list[str]]: + """Random bid/ask ladder around reference, distributed across the spread band.""" + min_price = reference * (1 - max_deviation_pct) + max_price = reference * (1 + max_deviation_pct) + + bid_range = reference - min_price + bids: list[str] = [] + for i in range(num_levels): + offset = bid_range * Decimal(random.uniform(0.1, 1.0)) * Decimal(i + 1) / Decimal(num_levels) # nosec B311 + price = round_to_tick(reference - offset, tick_size) + if price >= min_price: + bids.append(str(price)) + bids = sorted(set(bids), key=Decimal, reverse=True)[:num_levels] + + ask_range = max_price - reference + asks: list[str] = [] + for i in range(num_levels): + offset = ask_range * Decimal(random.uniform(0.1, 1.0)) * Decimal(i + 1) / Decimal(num_levels) # nosec B311 + price = round_to_tick(reference + offset, tick_size) + if price <= max_price: + asks.append(str(price)) + asks = sorted(set(asks), key=Decimal)[:num_levels] + + return bids, asks + + +def generate_single_price( + is_buy: bool, + reference: Decimal, + max_deviation_pct: Decimal, + tick_size: Decimal, + best_bid: Decimal | None, + best_ask: Decimal | None, +) -> Decimal: + """Single random price that doesn't cross our own spread (avoids self-match).""" + min_price = reference * (1 - max_deviation_pct) + max_price = reference * (1 + max_deviation_pct) + + if is_buy: + lower_bound = min_price + upper_bound = reference - tick_size + if best_ask is not None: + upper_bound = min(upper_bound, best_ask - tick_size) + else: + lower_bound = reference + tick_size + upper_bound = max_price + if best_bid is not None: + lower_bound = max(lower_bound, best_bid + tick_size) + + if lower_bound >= upper_bound: + return round_to_tick(min_price if is_buy else max_price, tick_size) + + price_range = upper_bound - lower_bound + random_offset = price_range * Decimal(random.uniform(0.0, 1.0)) # nosec B311 + return round_to_tick(lower_bound + random_offset, tick_size) + + +def compute_available_margin( + collateral_balance: Decimal, open_orders: list[OpenOrder], market_params: MarketParams +) -> Decimal: + """Budget minus the IM already locked up by our resting orders.""" + budget = collateral_balance * COLLATERAL_BUDGET_FRACTION + committed = sum( + (required_margin(o.price, o.qty, market_params.max_leverage) for o in open_orders), + start=Decimal("0"), + ) + return max(Decimal("0"), budget - committed) + + +# --------------------------------------------------------------------------- +# WebSocket handler +# --------------------------------------------------------------------------- + + +class WebSocketHandler: + """Subscribes to oracle price, wallet balances, order changes, perp executions.""" + + def __init__(self, state: MarketMakerState): + self.state = state + self._connected = threading.Event() + + def wait_for_connection(self, timeout: float = 10.0) -> bool: + return self._connected.wait(timeout) + + def on_open(self, ws: ReyaSocket) -> None: + logger.info("🔌 WebSocket connected, subscribing to channels...") + wallet = self.state.wallet_address + if not wallet: + logger.error("No wallet address set in state") + return + + ws.prices.price(self.state.oracle_symbol).subscribe() + ws.wallet.balances(wallet).subscribe() + ws.wallet.order_changes(wallet).subscribe() + ws.wallet.perp_executions(wallet).subscribe() + + logger.info(f" ✅ Subscribed to /v2/prices/{self.state.oracle_symbol}") + logger.info(f" ✅ Subscribed to /v2/wallet/{wallet}/accountBalances") + logger.info(f" ✅ Subscribed to /v2/wallet/{wallet}/openOrders") + logger.info(f" ✅ Subscribed to /v2/wallet/{wallet}/perpExecutions") + + def on_message(self, _ws: ReyaSocket, message: WebSocketMessage) -> None: + # Subscription confirmation — flip the connected flag once we have all subs. + if isinstance(message, SubscribedMessagePayload): + logger.debug(f"Subscribed to {message.channel}") + self._connected.set() + return + + if isinstance(message, PriceUpdatePayload): + if message.data and message.data.oracle_price: + price = Decimal(message.data.oracle_price) + if self.state.market_params: + price = round_to_tick(price, self.state.market_params.tick_size) + self.state.update_price(price) + return + + if isinstance(message, AccountBalanceUpdatePayload): + for balance in message.data: + if balance.account_id != self.state.account_id: + continue + if balance.asset == COLLATERAL_ASSET: + self.state.update_collateral(Decimal(balance.real_balance)) + return + + if isinstance(message, OrderChangeUpdatePayload): + for order in message.data: + if order.symbol != self.state.symbol: + continue + qty = Decimal(order.qty) if order.qty else Decimal("0") + cum_qty = Decimal(order.cum_qty) if order.cum_qty else Decimal("0") + is_buy = order.side.value == "B" + self.state.update_order( + order_id=order.order_id, + status=order.status.value, + price=Decimal(order.limit_px), + qty=qty, + cum_qty=cum_qty, + is_buy=is_buy, + ) + return + + if isinstance(message, WalletPerpExecutionUpdatePayload): + for execution in message.data: + if execution.symbol != self.state.symbol: + continue + # Only log our side of the fill (taker or maker). Either way + # the order_id is the one that hit, and we use it for logging. + order_id = execution.taker_order_id or execution.maker_order_id + if order_id is None: + continue + self.state.log_execution( + order_id=order_id, + qty=execution.qty, + price=execution.price, + side=execution.side.value, + ) + return + + def on_error(self, _ws: ReyaSocket, error: Exception) -> None: + logger.error(f"WebSocket error: {error}") + + def on_close(self, _ws: ReyaSocket, close_status_code: int, close_msg: str) -> None: + logger.warning(f"WebSocket closed: {close_status_code} - {close_msg}") + self._connected.clear() + + +# --------------------------------------------------------------------------- +# REST helpers (used for bootstrap + periodic resync) +# --------------------------------------------------------------------------- + + +async def fetch_market_definition(client: ReyaTradingClient, symbol: str) -> MarketParams: + """Look up perp market params via ``/marketDefinitions``.""" + definitions = await client.reference.get_market_definitions() + for market in definitions: + if market.symbol == symbol: + return MarketParams( + symbol=market.symbol, + tick_size=Decimal(market.tick_size), + min_order_qty=Decimal(market.min_order_qty), + qty_step_size=Decimal(market.qty_step_size), + max_leverage=market.max_leverage, + ) + raise RuntimeError(f"Perp market definition not found for symbol: {symbol}") + + +async def fetch_initial_state(client: ReyaTradingClient, state: MarketMakerState) -> None: + market_params = state.market_params + account_id = state.account_id + if not market_params or not account_id: + raise RuntimeError("Market params and account_id must be set before fetching initial state") + + logger.info(f" Fetching oracle price for {state.oracle_symbol}...") + price_info = await client.markets.get_price(state.oracle_symbol) + if price_info and price_info.oracle_price: + state.reference_price = round_to_tick(Decimal(price_info.oracle_price), market_params.tick_size) + + logger.info(" Fetching account balances...") + balances = await client.get_account_balances() + for balance in balances: + if balance.account_id == account_id and balance.asset == COLLATERAL_ASSET: + state.collateral_balance = Decimal(balance.real_balance) + + logger.info(" Fetching open orders...") + open_orders = await client.get_open_orders() + for order in open_orders: + if order.symbol != state.symbol: + continue + qty = Decimal(order.qty) if order.qty else Decimal("0") + cum_qty = Decimal(order.cum_qty) if order.cum_qty else Decimal("0") + remaining_qty = qty - cum_qty + is_buy = order.side.value == "B" + state.open_orders[order.order_id] = OpenOrder( + order_id=order.order_id, price=Decimal(order.limit_px), qty=remaining_qty, is_buy=is_buy + ) + + +async def refresh_state_from_rest(client: ReyaTradingClient, state: MarketMakerState) -> None: + """Re-sync open orders from REST (defends against WS gaps / missed events).""" + try: + open_orders = await client.get_open_orders() + fresh: dict[str, OpenOrder] = {} + for order in open_orders: + if order.symbol != state.symbol: + continue + qty = Decimal(order.qty) if order.qty else Decimal("0") + cum_qty = Decimal(order.cum_qty) if order.cum_qty else Decimal("0") + remaining_qty = qty - cum_qty + is_buy = order.side.value == "B" + fresh[order.order_id] = OpenOrder( + order_id=order.order_id, price=Decimal(order.limit_px), qty=remaining_qty, is_buy=is_buy + ) + state.sync_orders(fresh) + except RECOVERABLE_EXC as e: + logger.warning(f"Failed to refresh state from REST: {e}") + + +# --------------------------------------------------------------------------- +# Order placement / replacement +# --------------------------------------------------------------------------- + + +async def place_single_order( + client: ReyaTradingClient, + symbol: str, + price: str, + is_buy: bool, + market_params: MarketParams, + available_margin: Decimal, + max_retries: int = 3, +) -> tuple[bool, Decimal]: + """Place a GTC limit order. Retries with min qty on margin-rejections so + we always make at least *some* book contribution if margin headroom is tight.""" + price_decimal = Decimal(price) + side = "bid" if is_buy else "ask" + + max_qty = affordable_qty(price_decimal, available_margin, market_params) + if max_qty >= market_params.min_order_qty: + qty = generate_random_qty(market_params.min_order_qty, max_qty, market_params.qty_step_size) + else: + # Local margin tracking says insufficient — still try with min qty + # since real on-chain margin can have more headroom than our estimate. + qty = str(market_params.min_order_qty) + logger.debug(f" Local budget low, trying {side} @ ${price} with min qty={qty}") + + for attempt in range(max_retries): + try: + deadline = int(time.time()) + GTC_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, + ) + ) + logger.info(f" Placed {side} @ ${price} qty={qty}") + return True, required_margin(price_decimal, Decimal(qty), market_params.max_leverage) + except RECOVERABLE_EXC as e: + err = str(e).lower() + if "insufficient" in err or "margin" in err or "balance" in err: + if attempt < max_retries - 1: + qty = str(market_params.min_order_qty) + logger.debug(f" Retrying {side} @ ${price} with min qty={qty}") + continue + logger.warning(f" Skipping {side} @ ${price} — API rejected (insufficient margin)") + else: + logger.warning(f" Failed to place {side} @ ${price}: {e}") + return False, Decimal("0") + return False, Decimal("0") + + +async def place_initial_ladder( + client: ReyaTradingClient, + symbol: str, + bids: list[str], + asks: list[str], + market_params: MarketParams, + available_margin: Decimal, +) -> int: + """Place the initial bid+ask ladder, tracking remaining margin budget as we go.""" + order_count = 0 + remaining_margin = available_margin + for price in bids: + success, margin_used = await place_single_order(client, symbol, price, True, market_params, remaining_margin) + if success: + order_count += 1 + remaining_margin -= margin_used + for price in asks: + success, margin_used = await place_single_order(client, symbol, price, False, market_params, remaining_margin) + if success: + order_count += 1 + remaining_margin -= margin_used + return order_count + + +def find_out_of_range_orders( + bids: list[OpenOrder], asks: list[OpenOrder], reference: Decimal, max_spread_pct: Decimal +) -> list[OpenOrder]: + """Orders sitting outside ±max_spread_pct from reference.""" + min_price = reference * (1 - max_spread_pct) + max_price = reference * (1 + max_spread_pct) + return [o for o in bids + asks if o.price < min_price or o.price > max_price] + + +async def cancel_and_replace_order( + client: ReyaTradingClient, + symbol: str, + account_id: int, + order: OpenOrder, + reference_price: Decimal, + market_params: MarketParams, + available_margin: Decimal, + remaining_bids: list[OpenOrder], + remaining_asks: list[OpenOrder], + cycle: int, + state: MarketMakerState, + reason: str = "", + max_retries: int = 3, +) -> bool: + """Cancel an order and place a new one at a fresh in-range price. + On stale-order cancel errors (race with fill/expiry), drop local state.""" + side = "bid" if order.is_buy else "ask" + best_bid = remaining_bids[0].price if remaining_bids else None + best_ask = remaining_asks[0].price if remaining_asks else None + + new_price = generate_single_price( + is_buy=order.is_buy, + reference=reference_price, + max_deviation_pct=state.max_spread_pct, + tick_size=market_params.tick_size, + best_bid=best_bid, + best_ask=best_ask, + ) + + # Margin available after cancelling this order (frees up its committed IM). + freed = required_margin(order.price, order.qty, market_params.max_leverage) + total_available_margin = available_margin + freed + max_qty = affordable_qty(new_price, total_available_margin, market_params) + + if max_qty < market_params.min_order_qty: + logger.warning(f"[{cycle:04d}] Skipping {side} replacement — insufficient margin") + await _safe_cancel(client, order, symbol, account_id, state, cycle, side) + return False + + new_qty = generate_random_qty(market_params.min_order_qty, max_qty, market_params.qty_step_size) + + cancelled = await _safe_cancel(client, order, symbol, account_id, state, cycle, side) + if not cancelled: + return False + reason_str = f" ({reason})" if reason else "" + logger.info( + f"[{cycle:04d}] Cancelled {side} @ ${order.price}{reason_str} " + f"→ placing new {side} @ ${new_price} qty={new_qty}" + ) + + await asyncio.sleep(0.1) + + qty_to_use = new_qty + for attempt in range(max_retries): + try: + deadline = int(time.time()) + GTC_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, + ) + ) + return True + except RECOVERABLE_EXC as e: + err = str(e).lower() + if "insufficient" in err or "margin" in err or "balance" in err: + if attempt < max_retries - 1: + qty_to_use = str(market_params.min_order_qty) + logger.debug(f"[{cycle:04d}] Retrying {side} @ ${new_price} with min qty={qty_to_use}") + continue + logger.warning(f"[{cycle:04d}] Failed to place new {side} @ ${new_price}: {e}") + return False + return False + + +async def _safe_cancel( + client: ReyaTradingClient, + order: OpenOrder, + symbol: str, + account_id: int, + state: MarketMakerState, + cycle: int, + side: str, +) -> bool: + """Cancel, tolerating 'Order not found' (cancel raced a fill/expiry). + Returns True if the order is gone after the call (either cancelled or + already missing — both are fine for the caller's purposes).""" + try: + await client.cancel_order(order_id=order.order_id, symbol=symbol, account_id=account_id) + return True + except RECOVERABLE_EXC as e: + err = str(e) + if "Order not found" in err or "CANCEL_ORDER_OTHER_ERROR" in err: + state.remove_order(order.order_id) + logger.info(f"[{cycle:04d}] Removed stale {side} @ ${order.price} from local state") + return True + logger.warning(f"[{cycle:04d}] Failed to cancel {side} @ ${order.price}: {e}") + return False + + +async def adjust_orders(client: ReyaTradingClient, state: MarketMakerState, cycle: int) -> None: + """One adjustment pass — prioritises evicting out-of-range orders first, + then nudges a random in-range order so the ladder gets refreshed over time.""" + market_params = state.market_params + account_id = state.account_id + if not market_params or not account_id: + return + + reference_price, collateral_balance, bids, asks = state.get_snapshot() + if reference_price == Decimal("0"): + logger.warning(f"[{cycle:04d}] No reference price available, skipping") + return + + available_margin = compute_available_margin(collateral_balance, bids + asks, market_params) + min_price = reference_price * (1 - state.max_spread_pct) + max_price = reference_price * (1 + state.max_spread_pct) + + # Pass 0: refill missing levels (self-healing — handles orders that + # disappeared without us cancelling them: expiry, ME restart, fills, + # anything outside the bot's view). Keeps both sides at NUM_LEVELS. + needed_bids = NUM_LEVELS - len(bids) + needed_asks = NUM_LEVELS - len(asks) + if needed_bids > 0 or needed_asks > 0: + logger.info( + f"[{cycle:04d}] 📉 Refilling: have {len(bids)} bids / {len(asks)} asks " + f"(target {NUM_LEVELS} each) — placing {needed_bids} bid(s) + {needed_asks} ask(s)" + ) + best_bid = bids[0].price if bids else None + best_ask = asks[0].price if asks else None + remaining_margin = available_margin + for _ in range(max(needed_bids, 0)): + new_price = generate_single_price( + is_buy=True, + reference=reference_price, + max_deviation_pct=state.max_spread_pct, + tick_size=market_params.tick_size, + best_bid=best_bid, + best_ask=best_ask, + ) + success, margin_used = await place_single_order( + client, state.symbol, str(new_price), True, market_params, remaining_margin + ) + if success: + remaining_margin -= margin_used + if best_bid is None or new_price > best_bid: + best_bid = new_price + for _ in range(max(needed_asks, 0)): + new_price = generate_single_price( + is_buy=False, + reference=reference_price, + max_deviation_pct=state.max_spread_pct, + tick_size=market_params.tick_size, + best_bid=best_bid, + best_ask=best_ask, + ) + success, margin_used = await place_single_order( + client, state.symbol, str(new_price), False, market_params, remaining_margin + ) + if success: + remaining_margin -= margin_used + if best_ask is None or new_price < best_ask: + best_ask = new_price + return + + # Pass 1: evict orders sitting outside the band. + out_of_range = find_out_of_range_orders(bids, asks, reference_price, state.max_spread_pct) + if out_of_range: + logger.info( + f"[{cycle:04d}] 📊 Oracle ${reference_price} | Range ${min_price:.2f} – ${max_price:.2f} | " + f"⚠️ {len(out_of_range)} order(s) out of range" + ) + for order in out_of_range: + remaining_bids = [o for o in bids if o.order_id != order.order_id] + remaining_asks = [o for o in asks if o.order_id != order.order_id] + await cancel_and_replace_order( + client=client, + symbol=state.symbol, + account_id=account_id, + order=order, + reference_price=reference_price, + market_params=market_params, + available_margin=available_margin, + remaining_bids=remaining_bids, + remaining_asks=remaining_asks, + cycle=cycle, + state=state, + reason="out of range", + ) + if order.is_buy: + bids = [o for o in bids if o.order_id != order.order_id] + else: + asks = [o for o in asks if o.order_id != order.order_id] + return # one batch per cycle + + # Pass 2: pick one random order to refresh (keeps the ladder dynamic + # without churning the whole book every cycle). + if not bids and not asks: + logger.warning(f"[{cycle:04d}] No open orders to adjust") + return + + if bids and asks: + adjust_bid_side = random.choice([True, False]) # nosec B311 + else: + adjust_bid_side = bool(bids) + + order_to_cancel = random.choice(bids) if adjust_bid_side else random.choice(asks) # nosec B311 + remaining_bids = [o for o in bids if o.order_id != order_to_cancel.order_id] + remaining_asks = [o for o in asks if o.order_id != order_to_cancel.order_id] + await cancel_and_replace_order( + client=client, + symbol=state.symbol, + account_id=account_id, + order=order_to_cancel, + reference_price=reference_price, + market_params=market_params, + available_margin=available_margin, + remaining_bids=remaining_bids, + remaining_asks=remaining_asks, + cycle=cycle, + state=state, + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +async def main(symbol: str, oracle_symbol: str, max_spread_pct: Decimal) -> None: + load_dotenv() + + logger.info("=" * 60) + logger.info(f"🚀 PERP Market Maker (WebSocket) for {symbol}") + logger.info("=" * 60) + + state = MarketMakerState(symbol=symbol, oracle_symbol=oracle_symbol, max_spread_pct=max_spread_pct) + perp_config = TradingConfig.from_env() + + async with ReyaTradingClient(config=perp_config) as client: + await client.start() + + account_id = perp_config.account_id + wallet_address = perp_config.owner_wallet_address + if not account_id: + raise ValueError("PERP_ACCOUNT_ID_1 environment variable is required") + if not wallet_address: + raise ValueError("PERP_WALLET_ADDRESS_1 environment variable is required") + + state.account_id = account_id + state.wallet_address = wallet_address + + logger.info(f" Fetching market definition for {symbol}...") + state.market_params = await fetch_market_definition(client, symbol) + market_params = state.market_params + + await fetch_initial_state(client, state) + + min_price = state.reference_price * (1 - state.max_spread_pct) + max_price = state.reference_price * (1 + state.max_spread_pct) + logger.info(f" Reference Price: ${state.reference_price} (from {oracle_symbol} oracle)") + logger.info(f" Price Range: ${min_price:.2f} – ${max_price:.2f} (±{state.max_spread_pct * 100}%)") + logger.info(f" Tick Size: {market_params.tick_size}") + logger.info(f" Min Order Qty: {market_params.min_order_qty}") + logger.info(f" Max Order Qty: {MAX_ORDER_QTY}") + logger.info(f" Qty Step Size: {market_params.qty_step_size}") + logger.info(f" Max Leverage: {market_params.max_leverage}x") + logger.info(f" {COLLATERAL_ASSET} Collateral: {state.collateral_balance}") + logger.info(f" Budget Fraction: {COLLATERAL_BUDGET_FRACTION} ({COLLATERAL_BUDGET_FRACTION * 100}%)") + logger.info(f" Open Orders: {len(state.open_orders)}") + logger.info(f" Levels: {NUM_LEVELS} bids / {NUM_LEVELS} asks") + logger.info(f" Refresh: Every {REFRESH_INTERVAL}s") + logger.info(f" Account ID: {account_id}") + logger.info(" Press Ctrl+C to stop") + logger.info("%s\n", "=" * 60) + + ws_url = os.environ.get("REYA_WS_URL", "wss://ws.reya.xyz/") + ws_handler = WebSocketHandler(state) + websocket = ReyaSocket( + url=ws_url, + on_open=ws_handler.on_open, + on_message=ws_handler.on_message, + on_error=ws_handler.on_error, + on_close=ws_handler.on_close, + ) + + logger.info("🔌 Connecting WebSocket...") + websocket.connect() + if not ws_handler.wait_for_connection(timeout=10.0): + logger.warning("WebSocket connection timeout, continuing with REST-only state refresh") + + logger.info("Cleaning up existing orders...") + await client.mass_cancel(symbol=symbol, account_id=account_id) + await asyncio.sleep(0.2) + state.open_orders.clear() + logger.info("✅ Order book cleaned\n") + + try: + logger.info("Placing initial depth ladder...") + available_margin = compute_available_margin(state.collateral_balance, [], market_params) + bid_prices, ask_prices = generate_quote_prices( + state.reference_price, state.max_spread_pct, NUM_LEVELS, market_params.tick_size + ) + order_count = await place_initial_ladder( + client, symbol, bid_prices, ask_prices, market_params, available_margin + ) + logger.info(f"✅ Initial setup complete: {order_count} orders") + logger.info(f" Bids: {', '.join(f'${b}' for b in bid_prices)}") + logger.info(f" Asks: {', '.join(f'${a}' for a in ask_prices)}\n") + + cycle = 0 + while True: + await asyncio.sleep(REFRESH_INTERVAL) + cycle += 1 + + # Halt if collateral falls below floor (e.g. drained by adverse selection). + if state.collateral_balance < MIN_COLLATERAL: + logger.warning( + f"[{cycle:04d}] ⚠️ {COLLATERAL_ASSET} collateral " + f"({state.collateral_balance}) below floor ({MIN_COLLATERAL})" + ) + logger.warning(f"[{cycle:04d}] 🛑 Stopping MM due to low collateral...") + break + + # Periodic REST resync defends against WS gaps / missed events. + if cycle % STATE_REFRESH_CYCLES == 0: + logger.info(f"[{cycle:04d}] 🔄 Refreshing state from REST API...") + await refresh_state_from_rest(client, state) + + await adjust_orders(client, state, cycle) + + except (KeyboardInterrupt, asyncio.CancelledError): + pass + finally: + logger.info("\n🛑 Shutting down...") + logger.info("Closing WebSocket...") + websocket.close() + logger.info("Mass-cancelling all orders...") + try: + await client.mass_cancel(symbol=symbol, account_id=account_id) + logger.info("✅ Market maker stopped") + except RECOVERABLE_EXC as e: + logger.warning(f"Cleanup failed: {e}") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Perp Market Maker — maintains a thin always-on depth ladder around the oracle price." + ) + parser.add_argument("--symbol", type=str, default=DEFAULT_SYMBOL, help=f"Perp symbol (default: {DEFAULT_SYMBOL})") + parser.add_argument( + "--oracle-symbol", + type=str, + default=DEFAULT_ORACLE_SYMBOL, + help=f"Oracle reference symbol (default: {DEFAULT_ORACLE_SYMBOL})", + ) + parser.add_argument( + "--max-spread", + type=float, + default=float(DEFAULT_MAX_SPREAD_PCT), + help=f"Max ±spread from reference as decimal (default: {DEFAULT_MAX_SPREAD_PCT})", + ) + return parser.parse_args() + + +if __name__ == "__main__": + try: + args = parse_args() + asyncio.run( + main(symbol=args.symbol, oracle_symbol=args.oracle_symbol, max_spread_pct=Decimal(str(args.max_spread))) + ) + except KeyboardInterrupt: + pass diff --git a/examples/websocket/perps/market_monitoring.py b/examples/websocket/perps/market_monitoring.py index 8b82c880..5c6565cb 100644 --- a/examples/websocket/perps/market_monitoring.py +++ b/examples/websocket/perps/market_monitoring.py @@ -80,11 +80,9 @@ def handle_market_summary_data(payload: MarketSummaryUpdatePayload) -> None: logger.info(f" ├─ Volume 24h: {market.volume24h}") logger.info(f" ├─ Price Change 24h: {market.px_change24h or 'N/A'}") logger.info(f" ├─ Funding Rate: {market.funding_rate}") - logger.info(f" ├─ Long OI: {market.long_oi_qty}") - logger.info(f" ├─ Short OI: {market.short_oi_qty}") logger.info(f" ├─ Total OI: {market.oi_qty}") - logger.info(f" ├─ Oracle Price: {market.throttled_oracle_price or 'N/A'}") - logger.info(f" └─ Pool Price: {market.throttled_pool_price or 'N/A'}") + logger.info(f" ├─ Mark Price: {market.mark_price or 'N/A'}") + logger.info(f" └─ Throttled Mid Price: {market.throttled_mid_price or 'N/A'}") def handle_market_perp_executions_data(payload: MarketPerpExecutionUpdatePayload) -> None: @@ -97,11 +95,13 @@ def handle_market_perp_executions_data(payload: MarketPerpExecutionUpdatePayload # Showcase individual execution data structure for i, execution in enumerate(payload.data[:5]): # Show first 5 executions logger.info(f" Execution {i + 1}: {execution.symbol}") - logger.info(f" ├─ Account ID: {execution.account_id}") + logger.info(f" ├─ Taker Account ID: {execution.taker_account_id}") + logger.info(f" ├─ Maker Account ID: {execution.maker_account_id}") logger.info(f" ├─ Side: {execution.side.value}") logger.info(f" ├─ Quantity: {execution.qty}") logger.info(f" ├─ Price: {execution.price}") - logger.info(f" ├─ Fee: {execution.fee}") + logger.info(f" ├─ Taker Fee: {execution.taker_fee}") + logger.info(f" ├─ Maker Fee: {execution.maker_fee}") logger.info(f" ├─ Type: {execution.type.value}") logger.info(f" ├─ Timestamp: {execution.timestamp}") logger.info(f" └─ Sequence: {execution.sequence_number}") diff --git a/examples/websocket/perps/wallet_monitoring.py b/examples/websocket/perps/wallet_monitoring.py index d3ec255b..3bffb92c 100644 --- a/examples/websocket/perps/wallet_monitoring.py +++ b/examples/websocket/perps/wallet_monitoring.py @@ -112,11 +112,13 @@ def handle_wallet_executions_data(payload: WalletPerpExecutionUpdatePayload) -> # Showcase individual execution data structure for i, execution in enumerate(payload.data[:5]): # Show first 5 executions logger.info(f" Execution {i + 1}: {execution.symbol}") - logger.info(f" ├─ Account ID: {execution.account_id}") + logger.info(f" ├─ Taker Account ID: {execution.taker_account_id}") + logger.info(f" ├─ Maker Account ID: {execution.maker_account_id}") logger.info(f" ├─ Side: {execution.side.value}") logger.info(f" ├─ Quantity: {execution.qty}") logger.info(f" ├─ Price: {execution.price}") - logger.info(f" ├─ Fee: {execution.fee}") + logger.info(f" ├─ Taker Fee: {execution.taker_fee}") + logger.info(f" ├─ Maker Fee: {execution.maker_fee}") logger.info(f" └─ Type: {execution.type.value}") if len(payload.data) > 5: diff --git a/examples/websocket/spot/depth_market_maker.py b/examples/websocket/spot/depth_market_maker.py index 2c671226..5cc024bd 100644 --- a/examples/websocket/spot/depth_market_maker.py +++ b/examples/websocket/spot/depth_market_maker.py @@ -19,12 +19,10 @@ Usage: python -m examples.websocket.spot.depth_market_maker -Press Ctrl+C to stop. All resting liquidity is cancelled during shutdown -(see the ``mass_cancel`` block at the bottom of ``main_async``). To leave -liquidity in the market on exit instead, comment that block out. +Press Ctrl+C to stop (will cancel all orders on exit). """ -from typing import Callable, Optional, TypeVar +from __future__ import annotations import argparse import asyncio @@ -32,11 +30,10 @@ import os import random import threading -from collections.abc import Awaitable +import time from dataclasses import dataclass, field from decimal import ROUND_DOWN, Decimal -import aiohttp from dotenv import load_dotenv # pip install python-dotenv from sdk.async_api.account_balance_update_payload import AccountBalanceUpdatePayload @@ -44,14 +41,20 @@ from sdk.async_api.price_update_payload import PriceUpdatePayload from sdk.async_api.subscribed_message_payload import SubscribedMessagePayload from sdk.async_api.wallet_spot_execution_update_payload import WalletSpotExecutionUpdatePayload -from sdk.open_api.exceptions import ApiException, ServiceException -from sdk.open_api.models.cancel_order_response import CancelOrderResponse +from sdk.open_api.exceptions import ApiException 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_websocket import ReyaSocket, WebSocketMessage +# Exceptions worth swallowing inside the MM loop. We want the bot to stay +# alive on transient REST hiccups (network blips → OSError) and SDK-side +# 4xx/5xx responses (ApiException + subclasses like BadRequestException) — +# the most common one in spot MM is "Order not found" when our cancel +# races a fill or expiry. Mirrors the perp script's pattern. +RECOVERABLE_EXC: tuple = (OSError, RuntimeError, ApiException) + logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") logger = logging.getLogger("market_maker_ws") @@ -61,96 +64,19 @@ # Market configuration (defaults, can be overridden via command line) DEFAULT_SYMBOL = "WETHRUSD" # Default spot trading pair symbol -DEFAULT_ORACLE_SYMBOL = "ETHRUSD" # Default oracle price symbol for reference pricing +DEFAULT_ORACLE_SYMBOL = "WETHRUSD" # Default oracle price symbol for reference pricing DEFAULT_MAX_SPREAD_PCT = Decimal("0.01") # ±1% from reference price (configurable via --max-spread) -MAX_ORDER_QTY = Decimal("1") # Maximum order quantity +MAX_ORDER_QTY = Decimal("0.01") # Maximum order quantity NUM_LEVELS = 10 # Number of price levels on each side REFRESH_INTERVAL = 5 # Seconds between quote adjustments 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 -# HTTP statuses we consider transient and worth retrying. -_TRANSIENT_HTTP_STATUSES = {408, 425, 429, 500, 502, 503, 504} - -T = TypeVar("T") - - -def _is_rate_limit_400(exc: "ApiException") -> bool: - """Reya's matching engine surfaces rate-limit rejections as HTTP 400 with - ``"rate limit"`` in the body, NOT as 429. The retry decision is body-based.""" - status = getattr(exc, "status", 0) or 0 - if status != 400: - return False - body = getattr(exc, "body", "") or "" - return "rate limit" in str(body).lower() - - -async def with_http_retry( - func: Callable[[], Awaitable[T]], - *, - op_name: str, - is_idempotent: bool = False, - max_retries: int = 4, - initial_backoff: float = 0.5, - backoff_factor: float = 2.0, -) -> T: - """Run an async REST call with exponential-backoff retry on transient errors. - - Retries on: ServiceException (5xx), ApiException with status in 408/425/429/500/502/503/504 - or HTTP 400 with a "rate limit" body, aiohttp.ClientError, asyncio.TimeoutError, OSError. - - NON-idempotent calls (default: ``is_idempotent=False``) are NOT retried — the - failure is re-raised on the first transient error so callers don't end up - with duplicate orders on a 504-after-server-accept. Pass ``is_idempotent=True`` - only when the call is genuinely idempotent (a pre-computed clientOrderId - that the server will dedupe on, ``cancel_order``, ``mass_cancel``, or any - read). See README / CHANGELOG. - - Lets through unchanged (no retry, no wrap): ApiException with non-transient 4xx - status, so the caller's branch logic (e.g. ``"Order not found"``, - ``CANCEL_ORDER_OTHER_ERROR``) still runs. - """ - backoff = initial_backoff - last_exc: Optional[Exception] = None - for attempt in range(max_retries + 1): - try: - return await func() - except ServiceException as e: - last_exc = e - status = getattr(e, "status", 0) - except ApiException as e: - status = getattr(e, "status", 0) or 0 - if status not in _TRANSIENT_HTTP_STATUSES and not _is_rate_limit_400(e): - raise - last_exc = e - except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e: - last_exc = e - status = 0 - - if not is_idempotent: - # Non-idempotent calls get exactly one shot: if it fails transient, - # the server may have still committed it (e.g. 504 after accept). - # Re-firing would create a duplicate order. - logger.error( - f"{op_name}: transient error on non-idempotent call (status={status}); " - f"refusing to retry to avoid duplicate-write — last_exc={last_exc!r}" - ) - raise last_exc - - if attempt < max_retries: - logger.warning( - f"{op_name}: transient error (status={status}, {type(last_exc).__name__}: {last_exc}) " - f"— retry {attempt + 1}/{max_retries} in {backoff:.1f}s" - ) - await asyncio.sleep(backoff) - backoff *= backoff_factor - else: - logger.error(f"{op_name}: gave up after {max_retries + 1} attempts ({last_exc})") - raise last_exc - - # Defensive: by construction the loop above either returns from the try or - # raises in the else branch. mypy doesn't see that — guard last_exc here. - raise RuntimeError(f"{op_name}: with_http_retry exited loop without return or raise") +# 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 @dataclass @@ -185,9 +111,9 @@ class MarketMakerState: max_spread_pct: Decimal = DEFAULT_MAX_SPREAD_PCT # Configurable max bid-ask spread # Market parameters (set once on startup) - market_params: Optional[MarketParams] = None - account_id: Optional[int] = None - wallet_address: Optional[str] = None + market_params: MarketParams | None = None + account_id: int | None = None + wallet_address: str | None = None # Dynamic state (updated via WebSocket) reference_price: Decimal = Decimal("0") @@ -308,10 +234,8 @@ def calculate_available_balance( asks: list[OpenOrder], ) -> tuple[Decimal, Decimal]: """Calculate available balance after subtracting committed amounts.""" - # ``sum(..., start=0)`` returns ``int(0)`` on an empty sequence, breaking - # the ``Decimal`` arithmetic that follows. Provide a ``Decimal('0')`` start. - committed_quote = sum((o.price * o.qty for o in bids), Decimal("0")) - committed_base = sum((o.qty for o in asks), Decimal("0")) + committed_quote = sum(o.price * o.qty for o in bids) + committed_base = sum(o.qty for o in asks) return ( max(Decimal("0"), base_balance - committed_base), @@ -431,10 +355,9 @@ def on_message(self, _ws: ReyaSocket, message: WebSocketMessage) -> None: if isinstance(message, PriceUpdatePayload): if message.data and message.data.oracle_price: price = Decimal(message.data.oracle_price) - if price > 0: - if self.state.market_params: - price = round_to_tick(price, self.state.market_params.tick_size) - self.state.update_price(price) + if self.state.market_params: + price = round_to_tick(price, self.state.market_params.tick_size) + self.state.update_price(price) return # Handle balance updates @@ -521,21 +444,9 @@ async def fetch_initial_state( # Fetch oracle price logger.info(f" Fetching oracle price for {state.oracle_symbol}...") - try: - price_info = await client.markets.get_price(state.oracle_symbol) - # Pydantic models don't define ``__bool__``, so a bare ``price_info`` - # check is unreachable-False; the real signal is the field value. - if price_info.oracle_price and Decimal(price_info.oracle_price) > 0: - state.reference_price = round_to_tick(Decimal(price_info.oracle_price), market_params.tick_size) - except (OSError, RuntimeError, ApiException) as e: - logger.warning(f" Failed to fetch oracle price for {state.oracle_symbol}: {e}") - - if state.reference_price == Decimal("0"): - logger.warning( - f" No oracle price available for {state.oracle_symbol} at startup. " - "Market making remains paused until the oracle publishes a price; " - "the first cycle that sees a non-zero reference_price will begin quoting." - ) + price_info = await client.markets.get_price(state.oracle_symbol) + if price_info and price_info.oracle_price: + state.reference_price = round_to_tick(Decimal(price_info.oracle_price), market_params.tick_size) # Fetch account balances logger.info(" Fetching account balances...") @@ -593,7 +504,7 @@ async def refresh_state_from_rest( ) state.sync_orders(fresh_orders) - except (OSError, RuntimeError, ApiException) as e: + except RECOVERABLE_EXC as e: logger.warning(f"Failed to refresh state from REST: {e}") @@ -604,14 +515,12 @@ async def place_single_order( is_buy: bool, market_params: MarketParams, available_balance: Decimal, + max_retries: int = 3, ) -> tuple[bool, Decimal]: - """Place a single order, attempting one downshift to ``min_order_qty`` on a - balance error. Returns ``(success, qty_used)``. - - Single create-order attempt at each qty: never re-fire the same lambda, - since each invocation of ``client.create_limit_order`` allocates a fresh - nonce + EIP-712 signature and the server has no way to dedupe. The MM - accepts a worse fill ratio over the risk of duplicate resting orders. + """ + Place a single order, retrying with minimum quantity if initial attempt fails. + Always attempts to place at least with min qty - let the API decide if balance is truly insufficient. + Returns (success, qty_used). """ price_decimal = Decimal(price) side = "bid" if is_buy else "ask" @@ -626,22 +535,17 @@ async def place_single_order( # Determine initial quantity to try if max_qty >= market_params.min_order_qty: + # Normal case: use random qty within affordable range qty = generate_random_qty(market_params.min_order_qty, max_qty, market_params.qty_step_size) else: # Local balance tracking says insufficient, but still try with min qty - # The actual on-chain balance might have more available. + # The actual on-chain balance might have more available qty = str(market_params.min_order_qty) logger.debug(f" Local balance low, trying {side} @ ${price} with min qty={qty}") - # Try once at the chosen qty; if a balance error comes back, try ONCE more - # at min_qty. That's the cap — no inner retry on create-order. - for is_downshift in (False, True): - if is_downshift: - qty = str(market_params.min_order_qty) - logger.debug(f" Retrying {side} @ ${price} with min qty={qty}") - # Bind qty into the lambda so a future refactor of with_http_retry - # that defers the callable can't accidentally read a mutated value. + for attempt in range(max_retries): try: + deadline = int(time.time()) + GTC_LIFETIME_S await client.create_limit_order( LimitOrderParameters( symbol=symbol, @@ -649,17 +553,24 @@ async def place_single_order( limit_px=price, qty=qty, time_in_force=TimeInForce.GTC, + expires_after=deadline, + deadline=deadline, ) ) logger.info(f" Adding {side} @ ${price} qty={qty}") qty_used = price_decimal * Decimal(qty) if is_buy else Decimal(qty) return True, qty_used - except (OSError, RuntimeError, ApiException) as e: + except RECOVERABLE_EXC as e: error_str = str(e).lower() + # Check if it's a balance-related error if "insufficient" in error_str or "balance" in error_str or "margin" in error_str: - if not is_downshift: + if attempt < max_retries - 1: + # Retry with minimum quantity + qty = str(market_params.min_order_qty) + logger.debug(f" Retrying {side} @ ${price} with min qty={qty}") continue - logger.warning(f" Skipping {side} @ ${price} — insufficient balance (confirmed by API)") + # All retries exhausted with balance errors - truly insufficient + logger.warning(f" Skipping {side} @ ${price} - insufficient balance (confirmed by API)") else: logger.warning(f"Failed to place {side} @ ${price}: {e}") return False, Decimal("0") @@ -742,6 +653,7 @@ async def cancel_and_replace_order( cycle: int, state: MarketMakerState, reason: str = "", + max_retries: int = 3, ) -> bool: """Cancel a specific order and replace it with a new one at a valid price. @@ -774,19 +686,10 @@ async def cancel_and_replace_order( if max_qty < market_params.min_order_qty: logger.warning(f"[{cycle:04d}] Skipping {side} replacement - insufficient balance") - order_id = order.order_id - - async def _cancel_no_replacement() -> CancelOrderResponse: - return await client.cancel_order(order_id=order_id, symbol=symbol, account_id=account_id) - try: - await with_http_retry( - _cancel_no_replacement, - op_name=f"cancel_order {side} @ ${order.price}", - is_idempotent=True, - ) + await client.cancel_order(order_id=order.order_id, symbol=symbol, account_id=account_id) logger.info(f"[{cycle:04d}] Cancelled {side} @ ${order.price} (no replacement - low balance)") - except (OSError, RuntimeError, ApiException) as e: + except RECOVERABLE_EXC as e: error_str = str(e) if "Order not found" in error_str or "CANCEL_ORDER_OTHER_ERROR" in error_str: state.remove_order(order.order_id) @@ -797,26 +700,19 @@ async def _cancel_no_replacement() -> CancelOrderResponse: new_qty = generate_random_qty(market_params.min_order_qty, max_qty, market_params.qty_step_size) - # Cancel the existing order first. cancel_order is idempotent on the server - # (second cancel returns "Order not found"), so retrying transient errors - # is safe. - order_id_to_cancel = order.order_id - - async def _cancel_for_replacement() -> CancelOrderResponse: - return await client.cancel_order(order_id=order_id_to_cancel, symbol=symbol, account_id=account_id) - + # Cancel the existing order first try: - await with_http_retry( - _cancel_for_replacement, - op_name=f"cancel_order {side} @ ${order.price}", - is_idempotent=True, + await client.cancel_order( + order_id=order.order_id, + symbol=symbol, + account_id=account_id, ) reason_str = f" ({reason})" if reason else "" logger.info( f"[{cycle:04d}] Cancelling {side} @ ${order.price}{reason_str} " f"→ Adding new {side} @ ${new_price} qty={new_qty}" ) - except (OSError, RuntimeError, ApiException) as e: + except RECOVERABLE_EXC as e: error_str = str(e) if "Order not found" in error_str or "CANCEL_ORDER_OTHER_ERROR" in error_str: state.remove_order(order.order_id) @@ -827,15 +723,11 @@ async def _cancel_for_replacement() -> CancelOrderResponse: await asyncio.sleep(0.1) - # Single create-order attempt at the chosen qty; one downshift to - # min_qty on a balance error. No inner retry on create — the new nonce - # per call would otherwise cause duplicate resting orders. + # Try to place the new order, retrying with min qty if balance issues qty_to_use = new_qty - for is_downshift in (False, True): - if is_downshift: - qty_to_use = str(market_params.min_order_qty) - logger.debug(f"[{cycle:04d}] Retrying {side} @ ${new_price} with min qty={qty_to_use}") + for attempt in range(max_retries): try: + deadline = int(time.time()) + GTC_LIFETIME_S await client.create_limit_order( LimitOrderParameters( symbol=symbol, @@ -843,13 +735,18 @@ async def _cancel_for_replacement() -> CancelOrderResponse: limit_px=str(new_price), qty=qty_to_use, time_in_force=TimeInForce.GTC, + expires_after=deadline, + deadline=deadline, ) ) return True - except (OSError, RuntimeError, ApiException) as e: + except RECOVERABLE_EXC as e: error_str = str(e).lower() + # Check if it's a balance-related error - retry with min qty if "insufficient" in error_str or "balance" in error_str or "margin" in error_str: - if not is_downshift: + if attempt < max_retries - 1: + qty_to_use = str(market_params.min_order_qty) + logger.debug(f"[{cycle:04d}] Retrying {side} @ ${new_price} with min qty={qty_to_use}") continue logger.warning(f"[{cycle:04d}] Failed to place new {side} @ ${new_price}: {e}") return False @@ -873,15 +770,7 @@ async def adjust_orders( reference_price, base_balance, quote_balance, bids, asks = state.get_snapshot() if reference_price == Decimal("0"): - # No reference price means the oracle hasn't published yet OR has dropped - # out mid-run. Quoting on a synthetic fallback would expose the wallet to - # being swept at a price unrelated to fair value (e.g. $0.10 for ETH if - # we naively used a tiny constant). Halt this cycle instead; the next - # cycle will re-check the oracle. - logger.warning( - f"[{cycle:04d}] No reference price from oracle for {state.symbol} — skipping cycle. " - "Market making remains paused until the oracle publishes a price." - ) + logger.warning(f"[{cycle:04d}] No reference price available, skipping adjustment") return # Calculate available balance @@ -890,6 +779,52 @@ async def adjust_orders( min_price = reference_price * (1 - state.max_spread_pct) max_price = reference_price * (1 + state.max_spread_pct) + # Pass 0: refill missing levels (self-healing — handles orders that + # disappeared without us cancelling them: expiry, ME restart, fills, + # anything outside the bot's view). Keeps both sides at NUM_LEVELS. + needed_bids = NUM_LEVELS - len(bids) + needed_asks = NUM_LEVELS - len(asks) + if needed_bids > 0 or needed_asks > 0: + logger.info( + f"[{cycle:04d}] 📉 Refilling: have {len(bids)} bids / {len(asks)} asks " + f"(target {NUM_LEVELS} each) — placing {needed_bids} bid(s) + {needed_asks} ask(s)" + ) + best_bid = bids[0].price if bids else None + best_ask = asks[0].price if asks else None + for _ in range(max(needed_bids, 0)): + new_price = generate_single_price( + is_buy=True, + reference=reference_price, + max_deviation_pct=state.max_spread_pct, + tick_size=market_params.tick_size, + best_bid=best_bid, + best_ask=best_ask, + ) + success, qty_used = await place_single_order( + client, state.symbol, str(new_price), True, market_params, available_quote + ) + if success: + available_quote -= qty_used + if best_bid is None or new_price > best_bid: + best_bid = new_price + for _ in range(max(needed_asks, 0)): + new_price = generate_single_price( + is_buy=False, + reference=reference_price, + max_deviation_pct=state.max_spread_pct, + tick_size=market_params.tick_size, + best_bid=best_bid, + best_ask=best_ask, + ) + success, qty_used = await place_single_order( + client, state.symbol, str(new_price), False, market_params, available_base + ) + if success: + available_base -= qty_used + if best_ask is None or new_price < best_ask: + best_ask = new_price + return + # Check for out-of-range orders first out_of_range = find_out_of_range_orders(bids, asks, reference_price, state.max_spread_pct) @@ -1038,20 +973,11 @@ async def main(symbol: str, oracle_symbol: str, max_spread_pct: Decimal): if not ws_handler.wait_for_connection(timeout=10.0): logger.warning("WebSocket connection timeout, continuing with REST fallback") - # Clean up any existing orders from previous runs. Wrap in - # ``with_http_retry`` so a transient 504 on startup doesn't hard-fail - # the MM before the main loop even begins. Use ``sync_orders`` (which - # holds the state lock) instead of touching ``state.open_orders`` - # directly — the WebSocket daemon thread can fire ``on_message`` - # mutations concurrently. + # Clean up any existing orders from previous runs logger.info("Cleaning up existing orders...") - await with_http_retry( - lambda: client.mass_cancel(symbol=symbol, account_id=account_id), - op_name="mass_cancel (startup)", - is_idempotent=True, - ) + await client.mass_cancel(symbol=symbol, account_id=account_id) await asyncio.sleep(0.2) - state.sync_orders({}) + state.open_orders.clear() logger.info("✅ Order book cleaned\n") try: @@ -1105,19 +1031,12 @@ async def main(symbol: str, oracle_symbol: str, max_spread_pct: Decimal): logger.info("Closing WebSocket...") websocket.close() - # NOTE: Cancelling all liquidity on shutdown. - # Comment out the block below and uncomment the last line to leave liquidity in market on exit. logger.info("Cancelling all orders...") try: - await with_http_retry( - lambda: client.mass_cancel(symbol=symbol, account_id=account_id), - op_name="mass_cancel (cleanup)", - is_idempotent=True, - ) - logger.info("✅ All orders cancelled") - except (OSError, RuntimeError, ApiException) as e: + await client.mass_cancel(symbol=symbol, account_id=account_id) + logger.info("✅ Market maker stopped") + except RECOVERABLE_EXC as e: logger.warning(f"Cleanup failed: {e}") - # logger.info("✅ Market maker stopped (liquidity left in market)") def parse_args(): diff --git a/poetry.lock b/poetry.lock index 57a0896c..317b392d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.0 and should not be changed by hand. [[package]] name = "aiohappyeyeballs" @@ -7,7 +7,6 @@ description = "Happy Eyeballs for asyncio" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, @@ -20,7 +19,6 @@ description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b6fc902bff74d9b1879ad55f5404153e2b33a82e72a95c89cec5eb6cc9e92fbc"}, {file = "aiohttp-3.12.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:098e92835b8119b54c693f2f88a1dec690e20798ca5f5fe5f0520245253ee0af"}, @@ -129,7 +127,6 @@ description = "Simple retry client for aiohttp" optional = false python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "aiohttp_retry-2.9.1-py3-none-any.whl", hash = "sha256:66d2759d1921838256a05a3f80ad7e724936f083e35be5abb5e16eed6be6dc54"}, {file = "aiohttp_retry-2.9.1.tar.gz", hash = "sha256:8eb75e904ed4ee5c2ec242fefe85bf04240f685391c4879d8f541d6028ff01f1"}, @@ -145,7 +142,6 @@ description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, @@ -162,7 +158,6 @@ description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -175,7 +170,6 @@ description = "High-level concurrency and networking framework on top of asyncio optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1"}, {file = "anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6"}, @@ -196,7 +190,6 @@ description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, @@ -217,7 +210,7 @@ description = "The ultimate Python library in building OAuth and OpenID Connect optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "authlib-1.6.3-py2.py3-none-any.whl", hash = "sha256:7ea0f082edd95a03b7b72edac65ec7f8f68d703017d7e37573aee4fc603f2a48"}, {file = "authlib-1.6.3.tar.gz", hash = "sha256:9f7a982cc395de719e4c2215c5707e7ea690ecf84f1ab126f28c053f4219e610"}, @@ -233,7 +226,6 @@ description = "efficient arrays of booleans -- C extension" optional = false python-versions = "*" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "bitarray-3.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a05982bb49c73463cb0f0f4bed2d8da82631708a2c2d1926107ba99651b419ec"}, {file = "bitarray-3.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d30e7daaf228e3d69cdd8b02c0dd4199cec034c4b93c80109f56f4675a6db957"}, @@ -378,7 +370,7 @@ description = "The uncompromising code formatter." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, @@ -424,7 +416,6 @@ description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"}, {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"}, @@ -437,7 +428,7 @@ description = "Foreign Function Interface for Python calling C code." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\" and platform_python_implementation != \"PyPy\"" files = [ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"}, {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"}, @@ -535,7 +526,7 @@ description = "Validate configuration and produce human readable error messages. optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, @@ -548,7 +539,6 @@ description = "The Real First Universal Charset Detector. Open, modern and activ optional = false python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"}, {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"}, @@ -638,7 +628,6 @@ description = "Python bindings for C-KZG-4844" optional = false python-versions = "*" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "ckzg-2.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:96d4a9764a9f616c9a8eb3bcf8c171f03a85bbbfdb7c16f3ead53988083eea25"}, {file = "ckzg-2.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6798af534394d1e4c05ca0f0a5abd714a6938551c402a072ba3f4bc27aa8b7ec"}, @@ -749,7 +738,7 @@ description = "Composable command line interface toolkit" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, @@ -765,7 +754,7 @@ description = "Cross-platform colored terminal text." optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\" and (sys_platform == \"win32\" or platform_system == \"Windows\")" +markers = "extra == \"dev\" and (sys_platform == \"win32\" or platform_system == \"Windows\")" files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, @@ -778,7 +767,7 @@ description = "Code coverage measurement for Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "coverage-7.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:79f0283ab5e6499fd5fe382ca3d62afa40fb50ff227676a3125d18af70eabf65"}, {file = "coverage-7.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4545e906f595ee8ab8e03e21be20d899bfc06647925bc5b224ad7e8c40e08b8"}, @@ -880,7 +869,7 @@ description = "cryptography is a package which provides cryptographic recipes an optional = true python-versions = "!=3.9.0,!=3.9.1,>=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "cryptography-45.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:3be4f21c6245930688bd9e162829480de027f8bf962ede33d4f8ba7d67a00cee"}, {file = "cryptography-45.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67285f8a611b0ebc0857ced2081e30302909f571a46bfa7a3cc0ad303fe015c6"}, @@ -941,7 +930,7 @@ description = "Cython implementation of Toolz: High performance functional utili optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and implementation_name == \"cpython\"" +markers = "implementation_name == \"cpython\"" files = [ {file = "cytoolz-1.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cec9af61f71fc3853eb5dca3d42eb07d1f48a4599fa502cbe92adde85f74b042"}, {file = "cytoolz-1.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:140bbd649dbda01e91add7642149a5987a7c3ccc251f2263de894b89f50b6608"}, @@ -1058,7 +1047,7 @@ description = "Distribution utilities" optional = true python-versions = "*" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, @@ -1071,7 +1060,7 @@ description = "A parser for Python dependency files" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "dparse-0.6.4-py3-none-any.whl", hash = "sha256:fbab4d50d54d0e739fbb4dedfc3d92771003a5b9aa8545ca7a7045e3b174af57"}, {file = "dparse-0.6.4.tar.gz", hash = "sha256:90b29c39e3edc36c6284c82c4132648eaf28a01863eb3c231c2512196132201a"}, @@ -1093,7 +1082,6 @@ description = "eth_abi: Python utilities for working with Ethereum ABI definitio optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "eth_abi-5.2.0-py3-none-any.whl", hash = "sha256:17abe47560ad753f18054f5b3089fcb588f3e3a092136a416b6c1502cb7e8877"}, {file = "eth_abi-5.2.0.tar.gz", hash = "sha256:178703fa98c07d8eecd5ae569e7e8d159e493ebb6eeb534a8fe973fbc4e40ef0"}, @@ -1117,7 +1105,6 @@ description = "eth-account: Sign Ethereum transactions and messages with local p optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "eth_account-0.13.7-py3-none-any.whl", hash = "sha256:39727de8c94d004ff61d10da7587509c04d2dc7eac71e04830135300bdfc6d24"}, {file = "eth_account-0.13.7.tar.gz", hash = "sha256:5853ecbcbb22e65411176f121f5f24b8afeeaf13492359d254b16d8b18c77a46"}, @@ -1126,7 +1113,7 @@ files = [ [package.dependencies] bitarray = ">=2.4.0" ckzg = ">=2.0.0" -eth-abi = ">=4.0.0-b.2" +eth-abi = ">=4.0.0b2" eth-keyfile = ">=0.7.0,<0.9.0" eth-keys = ">=0.4.0" eth-rlp = ">=2.1.0" @@ -1147,7 +1134,6 @@ description = "eth-hash: The Ethereum hashing function, keccak256, sometimes (er optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "eth_hash-0.7.1-py3-none-any.whl", hash = "sha256:0fb1add2adf99ef28883fd6228eb447ef519ea72933535ad1a0b28c6f65f868a"}, {file = "eth_hash-0.7.1.tar.gz", hash = "sha256:d2411a403a0b0a62e8247b4117932d900ffb4c8c64b15f92620547ca5ce46be5"}, @@ -1170,7 +1156,6 @@ description = "eth-keyfile: A library for handling the encrypted keyfiles used t optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "eth_keyfile-0.8.1-py3-none-any.whl", hash = "sha256:65387378b82fe7e86d7cb9f8d98e6d639142661b2f6f490629da09fddbef6d64"}, {file = "eth_keyfile-0.8.1.tar.gz", hash = "sha256:9708bc31f386b52cca0969238ff35b1ac72bd7a7186f2a84b86110d3c973bec1"}, @@ -1193,7 +1178,6 @@ description = "eth-keys: Common API for Ethereum key operations" optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "eth_keys-0.7.0-py3-none-any.whl", hash = "sha256:b0cdda8ffe8e5ba69c7c5ca33f153828edcace844f67aabd4542d7de38b159cf"}, {file = "eth_keys-0.7.0.tar.gz", hash = "sha256:79d24fd876201df67741de3e3fefb3f4dbcbb6ace66e47e6fe662851a4547814"}, @@ -1216,7 +1200,6 @@ description = "eth-rlp: RLP definitions for common Ethereum objects in Python" optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "eth_rlp-2.2.0-py3-none-any.whl", hash = "sha256:5692d595a741fbaef1203db6a2fedffbd2506d31455a6ad378c8449ee5985c47"}, {file = "eth_rlp-2.2.0.tar.gz", hash = "sha256:5e4b2eb1b8213e303d6a232dfe35ab8c29e2d3051b86e8d359def80cd21db83d"}, @@ -1239,7 +1222,6 @@ description = "eth-typing: Common type annotations for ethereum python packages" optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "eth_typing-5.2.1-py3-none-any.whl", hash = "sha256:b0c2812ff978267563b80e9d701f487dd926f1d376d674f3b535cfe28b665d3d"}, {file = "eth_typing-5.2.1.tar.gz", hash = "sha256:7557300dbf02a93c70fa44af352b5c4a58f94e997a0fd6797fb7d1c29d9538ee"}, @@ -1260,7 +1242,6 @@ description = "eth-utils: Common utility functions for python code that interact optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "eth_utils-5.3.1-py3-none-any.whl", hash = "sha256:1f5476d8f29588d25b8ae4987e1ffdfae6d4c09026e476c4aad13b32dda3ead0"}, {file = "eth_utils-5.3.1.tar.gz", hash = "sha256:c94e2d2abd024a9a42023b4ddc1c645814ff3d6a737b33d5cfd890ebf159c2d1"}, @@ -1285,7 +1266,7 @@ description = "A platform independent file lock." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d"}, {file = "filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58"}, @@ -1298,7 +1279,7 @@ description = "the modular source code checker: pep8 pyflakes and co" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, @@ -1316,7 +1297,6 @@ description = "A list-like structure which implements collections.abc.MutableSeq optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, @@ -1431,7 +1411,6 @@ description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, @@ -1444,7 +1423,6 @@ description = "hexbytes: Python `bytes` subclass that decodes hex, with a readab optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "hexbytes-1.3.1-py3-none-any.whl", hash = "sha256:da01ff24a1a9a2b1881c4b85f0e9f9b0f51b526b379ffa23832ae7899d29c2c7"}, {file = "hexbytes-1.3.1.tar.gz", hash = "sha256:a657eebebdfe27254336f98d8af6e2236f3f83aed164b87466b6cf6c5f5a4765"}, @@ -1462,7 +1440,6 @@ description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, @@ -1485,7 +1462,6 @@ description = "The next generation HTTP client." optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, @@ -1511,7 +1487,7 @@ description = "File identification library for Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e"}, {file = "identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a"}, @@ -1527,7 +1503,6 @@ description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1543,7 +1518,7 @@ description = "brain-dead simple config-ini parsing" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, @@ -1556,7 +1531,7 @@ description = "A Python utility / library to sort Python imports." optional = true python-versions = ">=3.8.0" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, @@ -1572,7 +1547,7 @@ description = "A very fast and expressive template engine." optional = true python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, @@ -1591,7 +1566,7 @@ description = "Lightweight pipelining with Python functions" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "joblib-1.5.2-py3-none-any.whl", hash = "sha256:4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241"}, {file = "joblib-1.5.2.tar.gz", hash = "sha256:3faa5c39054b2f03ca547da9b2f52fde67c06240c31853f306aea97f13647b55"}, @@ -1604,7 +1579,7 @@ description = "LZ4 Bindings for Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "lz4-4.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f170abb8416c4efca48e76cac2c86c3185efdf841aecbe5c190121c42828ced0"}, {file = "lz4-4.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d33a5105cd96ebd32c3e78d7ece6123a9d2fb7c18b84dec61f27837d9e0c496c"}, @@ -1661,7 +1636,7 @@ description = "Python port of markdown-it. Markdown parsing, done right!" optional = true python-versions = ">=3.10" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, @@ -1686,7 +1661,7 @@ description = "Safely add untrusted strings to HTML/XML markup." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -1758,7 +1733,7 @@ description = "A lightweight library for converting complex datatypes to and fro optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "marshmallow-4.0.1-py3-none-any.whl", hash = "sha256:72f14ef346f81269dbddee891bac547dda1501e9e08b6a809756ea3dbb7936a1"}, {file = "marshmallow-4.0.1.tar.gz", hash = "sha256:e1d860bd262737cb2d34e1541b84cb52c32c72c9474e3fe6f30f137ef8b0d97f"}, @@ -1776,7 +1751,7 @@ description = "McCabe checker, plugin for flake8" optional = true python-versions = ">=3.6" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, @@ -1789,7 +1764,7 @@ description = "Markdown URL utilities" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, @@ -1802,7 +1777,6 @@ description = "multidict implementation" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2be5b7b35271f7fff1397204ba6708365e3d773579fe2a30625e16c4b4ce817"}, {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12f4581d2930840295c461764b9a65732ec01250b46c6b2c510d7ee68872b140"}, @@ -1923,7 +1897,7 @@ description = "Optional static typing for Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "mypy-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3fbe6d5555bf608c47203baa3e72dbc6ec9965b3d7c318aa9a4ca76f465bd972"}, {file = "mypy-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:80ef5c058b7bce08c83cac668158cb7edea692e458d21098c7d3bce35a5d43e7"}, @@ -1984,7 +1958,7 @@ description = "Type system extensions for programs checked with the mypy type ch optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, @@ -1997,7 +1971,7 @@ description = "Natural Language Toolkit" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "nltk-3.9.1-py3-none-any.whl", hash = "sha256:4fa26829c5b00715afe3061398a8989dc643b92ce7dd93fb4585a70930d168a1"}, {file = "nltk-3.9.1.tar.gz", hash = "sha256:87d127bd3de4bd89a4f81265e5fa59cb1b199b27440175370f7417d2bc7ae868"}, @@ -2024,7 +1998,7 @@ description = "Node.js virtual environment builder" optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, @@ -2037,7 +2011,7 @@ description = "Core utilities for Python packages" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, @@ -2050,7 +2024,6 @@ description = "(Soon to be) the fastest pure-Python PEG parser I could muster" optional = false python-versions = "*" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "parsimonious-0.10.0-py3-none-any.whl", hash = "sha256:982ab435fabe86519b57f6b35610aa4e4e977e9f02a14353edf4bbc75369fc0f"}, {file = "parsimonious-0.10.0.tar.gz", hash = "sha256:8281600da180ec8ae35427a4ab4f7b82bfec1e3d1e52f80cb60ea82b9512501c"}, @@ -2066,7 +2039,7 @@ description = "Utility library for gitignore style pattern matching of file path optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, @@ -2079,7 +2052,7 @@ description = "A small Python package for determining appropriate platform-speci optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, @@ -2097,7 +2070,7 @@ description = "plugin and hook calling mechanisms for python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, @@ -2114,7 +2087,7 @@ description = "A framework for managing and maintaining multi-language pre-commi optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8"}, {file = "pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16"}, @@ -2134,7 +2107,6 @@ description = "Accelerated property cache" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, @@ -2243,7 +2215,7 @@ description = "Cross-platform lib for process and system monitoring in Python. optional = true python-versions = ">=3.6" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25"}, {file = "psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da"}, @@ -2268,7 +2240,7 @@ description = "Python style guide checker" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, @@ -2281,7 +2253,7 @@ description = "C parser in Python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation != \"PyPy\" and extra == \"dev\" and implementation_name != \"PyPy\"" +markers = "extra == \"dev\" and implementation_name != \"PyPy\" and platform_python_implementation != \"PyPy\"" files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -2294,7 +2266,6 @@ description = "Cryptographic library for Python" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, @@ -2346,7 +2317,6 @@ description = "Data validation using Python type hints" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12"}, {file = "pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f"}, @@ -2356,8 +2326,8 @@ files = [ annotated-types = ">=0.6.0" pydantic-core = "2.23.4" typing-extensions = [ - {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, {version = ">=4.6.1", markers = "python_version < \"3.13\""}, + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, ] [package.extras] @@ -2371,7 +2341,6 @@ description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "pydantic_core-2.23.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:b10bd51f823d891193d4717448fab065733958bdb6a6b351967bd349d48d5c9b"}, {file = "pydantic_core-2.23.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4fc714bdbfb534f94034efaa6eadd74e5b93c8fa6315565a222f7b6f42ca1166"}, @@ -2474,7 +2443,7 @@ description = "passive checker of Python programs" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, @@ -2487,7 +2456,7 @@ description = "Pygments is a syntax highlighting package written in Python." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -2503,7 +2472,7 @@ description = "pytest: simple powerful testing with Python" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, @@ -2526,7 +2495,7 @@ description = "Pytest support for asyncio" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, @@ -2546,7 +2515,7 @@ description = "Pytest plugin for measuring coverage." optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5"}, {file = "pytest_cov-6.2.1.tar.gz", hash = "sha256:25cc6cc0a5358204b8108ecedc51a9b57b34cc6b8c967cc2c01a4e00d8a67da2"}, @@ -2567,7 +2536,7 @@ description = "A pytest plugin powered by VCR.py to record and replay HTTP traff optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "pytest_recording-0.13.4-py3-none-any.whl", hash = "sha256:ad49a434b51b1c4f78e85b1e6b74fdcc2a0a581ca16e52c798c6ace971f7f439"}, {file = "pytest_recording-0.13.4.tar.gz", hash = "sha256:568d64b2a85992eec4ae0a419c855d5fd96782c5fb016784d86f18053792768c"}, @@ -2588,7 +2557,6 @@ description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2604,7 +2572,6 @@ description = "Read key-value pairs from a .env file and set them as environment optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, @@ -2620,7 +2587,6 @@ description = "Unicode normalization forms (NFC, NFKC, NFD, NFKD). A library ind optional = false python-versions = ">=3.6" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "pyunormalize-16.0.0-py3-none-any.whl", hash = "sha256:c647d95e5d1e2ea9a2f448d1d95d8518348df24eab5c3fd32d2b5c3300a49152"}, {file = "pyunormalize-16.0.0.tar.gz", hash = "sha256:2e1dfbb4a118154ae26f70710426a52a364b926c9191f764601f5a8cb12761f7"}, @@ -2633,7 +2599,7 @@ description = "Python for Window Extensions" optional = false python-versions = "*" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and platform_system == \"Windows\"" +markers = "platform_system == \"Windows\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -2664,7 +2630,7 @@ description = "YAML parser and emitter for Python" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -2728,7 +2694,6 @@ description = "Alternative regular expression module, to replace re." optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "regex-2025.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c5aa2a6a73bf218515484b36a0d20c6ad9dc63f6339ff6224147b0e2c095ee55"}, {file = "regex-2025.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c2ff5c01d5e47ad5fc9d31bcd61e78c2fa0068ed00cab86b7320214446da766"}, @@ -2826,7 +2791,6 @@ description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -2849,7 +2813,7 @@ description = "Render rich text, tables, progress bars, syntax highlighting, mar optional = true python-versions = ">=3.8.0" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, @@ -2869,7 +2833,6 @@ description = "rlp: A package for Recursive Length Prefix encoding and decoding" optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "rlp-4.1.0-py3-none-any.whl", hash = "sha256:8eca394c579bad34ee0b937aecb96a57052ff3716e19c7a578883e767bc5da6f"}, {file = "rlp-4.1.0.tar.gz", hash = "sha256:be07564270a96f3e225e2c107db263de96b5bc1f27722d2855bd3459a08e95a9"}, @@ -2891,7 +2854,7 @@ description = "ruamel.yaml is a YAML parser/emitter that supports roundtrip pres optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "ruamel.yaml-0.18.15-py3-none-any.whl", hash = "sha256:148f6488d698b7a5eded5ea793a025308b25eca97208181b6a026037f391f701"}, {file = "ruamel.yaml-0.18.15.tar.gz", hash = "sha256:dbfca74b018c4c3fba0b9cc9ee33e53c371194a9000e694995e620490fd40700"}, @@ -2968,7 +2931,7 @@ description = "Scan dependencies for known vulnerabilities and licenses." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "safety-3.6.1-py3-none-any.whl", hash = "sha256:22bc89d4e6471aa0fce41952bb5f7cb5f2f126127976024fe55c641c5447ce0b"}, {file = "safety-3.6.1.tar.gz", hash = "sha256:4d021e61cb8be527274560e5729616155b42e5442ca54e62c57da40076d80fd4"}, @@ -3007,7 +2970,7 @@ description = "Schemas for Safety tools" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "safety_schemas-0.0.14-py3-none-any.whl", hash = "sha256:0bf6fc4aa5e474651b714cc9e427c862792946bf052b61d5c7bec4eac4c0f254"}, {file = "safety_schemas-0.0.14.tar.gz", hash = "sha256:49953f7a59e919572be25595a8946f9cbbcd2066fe3e160c9467d9d1d6d7af6a"}, @@ -3027,7 +2990,7 @@ description = "Easily download, build, install, upgrade, and uninstall Python pa optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, @@ -3049,7 +3012,7 @@ description = "Tool to Detect Surrounding Shell" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, @@ -3062,7 +3025,6 @@ description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -3075,7 +3037,6 @@ description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -3088,7 +3049,7 @@ description = "Retry code until it succeeds" optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, @@ -3105,7 +3066,6 @@ description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -3148,7 +3108,7 @@ description = "Style preserving TOML library" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, @@ -3161,7 +3121,7 @@ description = "List processing tools and functional utilities" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and (implementation_name == \"pypy\" or implementation_name == \"cpython\")" +markers = "implementation_name == \"pypy\" or implementation_name == \"cpython\"" files = [ {file = "toolz-1.0.0-py3-none-any.whl", hash = "sha256:292c8f1c4e7516bf9086f8850935c799a874039c8bcf959d47b600e4c44a6236"}, {file = "toolz-1.0.0.tar.gz", hash = "sha256:2c86e3d9a04798ac556793bced838816296a2f085017664e4995cb40a1047a02"}, @@ -3174,7 +3134,7 @@ description = "Fast, Extensible Progress Meter" optional = true python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, @@ -3197,7 +3157,7 @@ description = "Typer, build great CLIs. Easy to code. Based on Python type hints optional = true python-versions = ">=3.7" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824"}, {file = "typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580"}, @@ -3261,7 +3221,6 @@ description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, @@ -3311,7 +3270,7 @@ description = "Automatically mock your HTTP interactions to simplify and speed u optional = true python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "vcrpy-7.0.0-py2.py3-none-any.whl", hash = "sha256:55791e26c18daa363435054d8b35bd41a4ac441b6676167635d1b37a71dbe124"}, {file = "vcrpy-7.0.0.tar.gz", hash = "sha256:176391ad0425edde1680c5b20738ea3dc7fb942520a48d2993448050986b3a50"}, @@ -3320,8 +3279,8 @@ files = [ [package.dependencies] PyYAML = "*" urllib3 = [ - {version = "<2", markers = "platform_python_implementation == \"PyPy\""}, {version = "*", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.10\""}, + {version = "<2", markers = "platform_python_implementation == \"PyPy\""}, ] wrapt = "*" yarl = "*" @@ -3336,7 +3295,7 @@ description = "Virtual Python Environment builder" optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "virtualenv-20.33.1-py3-none-any.whl", hash = "sha256:07c19bc66c11acab6a5958b815cbcee30891cd1c2ccf53785a28651a0d8d8a67"}, {file = "virtualenv-20.33.1.tar.gz", hash = "sha256:1b44478d9e261b3fb8baa5e74a0ca3bc0e05f21aa36167bf9cbf850e542765b8"}, @@ -3358,7 +3317,6 @@ description = "web3: A Python library for interacting with Ethereum" optional = false python-versions = "<4,>=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "web3-7.13.0-py3-none-any.whl", hash = "sha256:4da7e953300577b7dadbaf430e5fd4479b127b1ad4910234b230fdcb8a49f735"}, {file = "web3-7.13.0.tar.gz", hash = "sha256:281795e0f5d404c1374e1771f6710bb43e0c975f3141366eb1680763edfb4806"}, @@ -3393,7 +3351,6 @@ description = "WebSocket client for Python with low level API options" optional = false python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, @@ -3411,7 +3368,6 @@ description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, @@ -3491,7 +3447,7 @@ description = "Module for decorators, wrappers and monkey patching." optional = true python-versions = ">=3.8" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\" and extra == \"dev\"" +markers = "extra == \"dev\"" files = [ {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"}, {file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"}, @@ -3583,7 +3539,6 @@ description = "Yet another URL library" optional = false python-versions = ">=3.9" groups = ["main"] -markers = "platform_python_implementation == \"PyPy\"" files = [ {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, diff --git a/pyproject.toml b/pyproject.toml index ba8e68ca..d2df592d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "reya-python-sdk" -version = "2.2.1.0" +version = "3.0.1.0" description = "SDK for interacting with Reya Labs APIs" authors = [ {name = "Reya Labs"} @@ -69,13 +69,13 @@ build-backend = "poetry.core.masonry.api" [tool.black] line-length = 120 -extend-exclude = "sdk/async_api" +extend-exclude = "sdk/async_api|sdk/async_exec_api" [tool.isort] # https://github.com/timothycrosley/isort/ py_version = 310 line_length = 120 -extend_skip_glob = ["sdk/async_api/*"] +extend_skip_glob = ["sdk/async_api/*", "sdk/async_exec_api/*"] known_typing = ["typing", "types", "typing_extensions", "mypy", "mypy_extensions"] sections = ["FUTURE", "TYPING", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"] @@ -119,8 +119,8 @@ ignore_errors = true [tool.pylint.'MESSAGES CONTROL'] max-line-length = 120 -max-module-lines=2000 -ignore-paths = ["sdk/async_api"] +max-module-lines=2050 +ignore-paths = ["sdk/async_api", "sdk/async_exec_api"] disable = """ consider-using-in, duplicate-code, diff --git a/scripts/probe_perp_gate.py b/scripts/probe_perp_gate.py new file mode 100644 index 00000000..a422e1b0 --- /dev/null +++ b/scripts/probe_perp_gate.py @@ -0,0 +1,73 @@ +"""Probe which perp markets on devnet have order-entry enabled. + +Walks every perp market in /v2/marketDefinitions and tries to post a tiny +far-from-market GTC sell as PERP_ACCOUNT_ID_1. Cancels each one immediately +on success. Used to find which symbols are past the matching-engine +PERP_OB_MARKET_IDS launch gate without doing any real trading. +""" + +from __future__ import annotations + +import asyncio +import logging + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api import ReyaTradingClient +from sdk.reya_rest_api.models import LimitOrderParameters + +logging.basicConfig(level=logging.WARNING) + + +async def main() -> None: + client = ReyaTradingClient() + await client.start() + try: + defs = await client.reference.get_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]}") + + for d in perp_defs: + symbol = d.symbol + # Get oracle price; place far-below sell so it can't match. + try: + price_resp = await client.markets.get_price(symbol) + oracle = float(price_resp.oracle_price) + except ApiException as e: + print(f" {symbol}: NO ORACLE PRICE ({e.body if hasattr(e, 'body') else e})") + continue + + far_sell_px = str(round(oracle * 2.0, 2)) + params = LimitOrderParameters( + symbol=symbol, + is_buy=False, + limit_px=far_sell_px, + qty=str(d.min_order_qty), + time_in_force=TimeInForce.GTC, + ) + + try: + resp = await client.create_limit_order(params) + order_id = resp.order_id + print(f" {symbol}: ✅ ORDER ENTRY ENABLED (order_id={order_id})") + if order_id: + try: + await client.cancel_order( + symbol=symbol, + account_id=client.config.account_id, + order_id=order_id, + ) + except ApiException as cancel_err: + print(f" (cancel failed: {cancel_err})") + except ApiException as e: + msg = str(e) + if "Perp order entry is not enabled" in msg: + print(f" {symbol}: ❌ GATED (Perp order entry not enabled)") + else: + print(f" {symbol}: ❌ OTHER ERROR — {msg.splitlines()[-1] if msg else e}") + finally: + await client.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/sdk/async_api/spot_execution_bust.py b/sdk/async_api/execution_bust.py similarity index 95% rename from sdk/async_api/spot_execution_bust.py rename to sdk/async_api/execution_bust.py index 1ad9bf79..a8c529d4 100644 --- a/sdk/async_api/spot_execution_bust.py +++ b/sdk/async_api/execution_bust.py @@ -2,7 +2,7 @@ from typing import Any, Dict, Optional from pydantic import model_serializer, model_validator, BaseModel, Field from sdk.async_api.side import Side -class SpotExecutionBust(BaseModel): +class ExecutionBust(BaseModel): symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') account_id: int = Field(alias='''accountId''') exchange_id: int = Field(alias='''exchangeId''') @@ -12,7 +12,7 @@ class SpotExecutionBust(BaseModel): qty: str = Field() side: Side = Field(description='''Order side (B = Buy/Bid, A = Ask/Sell)''') price: str = Field() - reason: str = Field(description='''Hex-encoded revert reason bytes''') + reason: str = Field(description='''Human Readable Reason String (decoded revert reason bytes)''') timestamp: int = Field() sequence_number: int = Field(alias='''sequenceNumber''') additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) diff --git a/sdk/async_api/execution_type.py b/sdk/async_api/execution_type.py index 70fb581d..5fab9bc3 100644 --- a/sdk/async_api/execution_type.py +++ b/sdk/async_api/execution_type.py @@ -3,4 +3,5 @@ class ExecutionType(Enum): ORDER_MATCH = "ORDER_MATCH" LIQUIDATION = "LIQUIDATION" - ADL = "ADL" \ No newline at end of file + ADL = "ADL" + DUST = "DUST" \ No newline at end of file diff --git a/sdk/async_api/market_spot_execution_bust_update_payload.py b/sdk/async_api/market_execution_bust_update_payload.py similarity index 69% rename from sdk/async_api/market_spot_execution_bust_update_payload.py rename to sdk/async_api/market_execution_bust_update_payload.py index eadb6d22..41e1dc70 100644 --- a/sdk/async_api/market_spot_execution_bust_update_payload.py +++ b/sdk/async_api/market_execution_bust_update_payload.py @@ -2,9 +2,9 @@ from typing import Any, List, Dict, Optional from pydantic import BaseModel, Field from sdk.async_api.channel_data_message_type import ChannelDataMessageType -from sdk.async_api.spot_execution_bust import SpotExecutionBust -class MarketSpotExecutionBustUpdatePayload(BaseModel): +from sdk.async_api.execution_bust import ExecutionBust +class MarketExecutionBustUpdatePayload(BaseModel): type: ChannelDataMessageType = Field(description='''Message type for channel data updates''') timestamp: float = Field(description='''Update timestamp (milliseconds)''') - channel: str = Field(description='''Channel pattern for market spot execution busts''') - data: List[SpotExecutionBust] = Field() + channel: str = Field(description='''Channel pattern for market execution busts (spot + perp)''') + data: List[ExecutionBust] = Field() diff --git a/sdk/async_api/market_summary.py b/sdk/async_api/market_summary.py index 66597c1d..9c221c08 100644 --- a/sdk/async_api/market_summary.py +++ b/sdk/async_api/market_summary.py @@ -5,17 +5,14 @@ class MarketSummary(BaseModel): symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') updated_at: int = Field(alias='''updatedAt''') - long_oi_qty: str = Field(alias='''longOiQty''') - short_oi_qty: str = Field(alias='''shortOiQty''') oi_qty: str = Field(alias='''oiQty''') funding_rate: str = Field(alias='''fundingRate''') long_funding_value: str = Field(alias='''longFundingValue''') short_funding_value: str = Field(alias='''shortFundingValue''') - funding_rate_velocity: str = Field(alias='''fundingRateVelocity''') volume24h: str = Field() px_change24h: Optional[str] = Field(default=None, alias='''pxChange24h''') - throttled_oracle_price: Optional[str] = Field(default=None, alias='''throttledOraclePrice''') - throttled_pool_price: Optional[str] = Field(default=None, alias='''throttledPoolPrice''') + mark_price: Optional[str] = Field(default=None, alias='''markPrice''') + throttled_mid_price: Optional[str] = Field(default=None, alias='''throttledMidPrice''') prices_updated_at: Optional[int] = Field(default=None, alias='''pricesUpdatedAt''') additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) @@ -37,13 +34,13 @@ def unwrap_additional_properties(cls, data): if not isinstance(data, dict): data = data.model_dump() json_properties = list(data.keys()) - known_object_properties = ['symbol', 'updated_at', 'long_oi_qty', 'short_oi_qty', 'oi_qty', 'funding_rate', 'long_funding_value', 'short_funding_value', 'funding_rate_velocity', 'volume24h', 'px_change24h', 'throttled_oracle_price', 'throttled_pool_price', 'prices_updated_at', 'additional_properties'] + known_object_properties = ['symbol', 'updated_at', 'oi_qty', 'funding_rate', 'long_funding_value', 'short_funding_value', 'volume24h', 'px_change24h', 'mark_price', 'throttled_mid_price', 'prices_updated_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 = ['symbol', 'updatedAt', 'longOiQty', 'shortOiQty', 'oiQty', 'fundingRate', 'longFundingValue', 'shortFundingValue', 'fundingRateVelocity', 'volume24h', 'pxChange24h', 'throttledOraclePrice', 'throttledPoolPrice', 'pricesUpdatedAt', 'additionalProperties'] + known_json_properties = ['symbol', 'updatedAt', 'oiQty', 'fundingRate', 'longFundingValue', 'shortFundingValue', 'volume24h', 'pxChange24h', 'markPrice', 'throttledMidPrice', 'pricesUpdatedAt', '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_api/order.py b/sdk/async_api/order.py index d259c8cd..7f3d3a65 100644 --- a/sdk/async_api/order.py +++ b/sdk/async_api/order.py @@ -15,9 +15,9 @@ class Order(BaseModel): cum_qty: Optional[str] = Field(default=None, alias='''cumQty''') side: Side = Field(description='''Order side (B = Buy/Bid, A = Ask/Sell)''') limit_px: str = Field(alias='''limitPx''') - order_type: OrderType = Field(description='''Order type, (LIMIT = Limit, TP = Take Profit, SL = Stop Loss)''', alias='''orderType''') + 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)''', default=None, alias='''timeInForce''') + 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''') reduce_only: Optional[bool] = Field(description='''Whether this is a reduce-only order, exclusively used for LIMIT IOC orders.''', default=None, alias='''reduceOnly''') status: OrderStatus = Field(description='''Order status''') created_at: int = Field(alias='''createdAt''') diff --git a/sdk/async_api/order_type.py b/sdk/async_api/order_type.py index f880c24d..c7b413b7 100644 --- a/sdk/async_api/order_type.py +++ b/sdk/async_api/order_type.py @@ -2,5 +2,5 @@ class OrderType(Enum): LIMIT = "LIMIT" - TP = "TP" - SL = "SL" \ No newline at end of file + STOP_LOSS = "STOP_LOSS" + TAKE_PROFIT = "TAKE_PROFIT" \ No newline at end of file diff --git a/sdk/async_api/perp_execution.py b/sdk/async_api/perp_execution.py index 05142d0b..cc7b337e 100644 --- a/sdk/async_api/perp_execution.py +++ b/sdk/async_api/perp_execution.py @@ -6,18 +6,26 @@ class PerpExecution(BaseModel): exchange_id: int = Field(alias='''exchangeId''') symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') - account_id: int = Field(alias='''accountId''') + taker_account_id: int = Field(alias='''takerAccountId''') + maker_account_id: Optional[int] = Field(default=None, alias='''makerAccountId''') + taker_order_id: Optional[str] = Field(description='''Order ID for the taker. Absent for legacy V2 executions and omitted when not meaningful.''', default=None, alias='''takerOrderId''') + maker_order_id: Optional[str] = Field(description='''Order ID for the maker. Absent for legacy V2 executions and omitted when not meaningful.''', default=None, alias='''makerOrderId''') qty: str = Field() side: Side = Field(description='''Order side (B = Buy/Bid, A = Ask/Sell)''') price: str = Field() - fee: str = Field() - opening_fee: Optional[str] = Field(default=None, alias='''openingFee''') + taker_fee: str = Field(alias='''takerFee''') + maker_fee: Optional[str] = Field(default=None, alias='''makerFee''') + taker_opening_fee: Optional[str] = Field(default=None, alias='''takerOpeningFee''') + maker_opening_fee: Optional[str] = Field(default=None, alias='''makerOpeningFee''') type: ExecutionType = Field(description='''Type of execution''') timestamp: int = Field() sequence_number: int = Field(alias='''sequenceNumber''') - realized_pnl: Optional[str] = Field(default=None, alias='''realizedPnl''') - price_variation_pnl: Optional[str] = Field(default=None, alias='''priceVariationPnl''') - funding_pnl: Optional[str] = Field(default=None, alias='''fundingPnl''') + taker_realized_pnl: Optional[str] = Field(default=None, alias='''takerRealizedPnl''') + maker_realized_pnl: Optional[str] = Field(default=None, alias='''makerRealizedPnl''') + taker_price_variation_pnl: Optional[str] = Field(default=None, alias='''takerPriceVariationPnl''') + maker_price_variation_pnl: Optional[str] = Field(default=None, alias='''makerPriceVariationPnl''') + taker_funding_pnl: Optional[str] = Field(default=None, alias='''takerFundingPnl''') + maker_funding_pnl: Optional[str] = Field(default=None, alias='''makerFundingPnl''') additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) @model_serializer(mode='wrap') @@ -38,13 +46,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', 'qty', 'side', 'price', 'fee', 'opening_fee', 'type', 'timestamp', 'sequence_number', 'realized_pnl', 'price_variation_pnl', 'funding_pnl', 'additional_properties'] + known_object_properties = ['exchange_id', 'symbol', 'taker_account_id', 'maker_account_id', 'taker_order_id', 'maker_order_id', 'qty', 'side', 'price', 'taker_fee', 'maker_fee', 'taker_opening_fee', 'maker_opening_fee', 'type', 'timestamp', 'sequence_number', 'taker_realized_pnl', 'maker_realized_pnl', 'taker_price_variation_pnl', 'maker_price_variation_pnl', 'taker_funding_pnl', 'maker_funding_pnl', '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', 'qty', 'side', 'price', 'fee', 'openingFee', 'type', 'timestamp', 'sequenceNumber', 'realizedPnl', 'priceVariationPnl', 'fundingPnl', 'additionalProperties'] + known_json_properties = ['exchangeId', 'symbol', 'takerAccountId', 'makerAccountId', 'takerOrderId', 'makerOrderId', 'qty', 'side', 'price', 'takerFee', 'makerFee', 'takerOpeningFee', 'makerOpeningFee', 'type', 'timestamp', 'sequenceNumber', 'takerRealizedPnl', 'makerRealizedPnl', 'takerPriceVariationPnl', 'makerPriceVariationPnl', 'takerFundingPnl', 'makerFundingPnl', '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_api/time_in_force.py b/sdk/async_api/time_in_force.py index e65ba4af..afd7cc1e 100644 --- a/sdk/async_api/time_in_force.py +++ b/sdk/async_api/time_in_force.py @@ -2,4 +2,5 @@ class TimeInForce(Enum): IOC = "IOC" - GTC = "GTC" \ No newline at end of file + GTC = "GTC" + GTT = "GTT" \ No newline at end of file diff --git a/sdk/async_api/wallet_spot_execution_bust_update_payload.py b/sdk/async_api/wallet_execution_bust_update_payload.py similarity index 69% rename from sdk/async_api/wallet_spot_execution_bust_update_payload.py rename to sdk/async_api/wallet_execution_bust_update_payload.py index 6e28fc22..6383f01f 100644 --- a/sdk/async_api/wallet_spot_execution_bust_update_payload.py +++ b/sdk/async_api/wallet_execution_bust_update_payload.py @@ -2,9 +2,9 @@ from typing import Any, List, Dict, Optional from pydantic import BaseModel, Field from sdk.async_api.channel_data_message_type import ChannelDataMessageType -from sdk.async_api.spot_execution_bust import SpotExecutionBust -class WalletSpotExecutionBustUpdatePayload(BaseModel): +from sdk.async_api.execution_bust import ExecutionBust +class WalletExecutionBustUpdatePayload(BaseModel): type: ChannelDataMessageType = Field(description='''Message type for channel data updates''') timestamp: float = Field(description='''Update timestamp (milliseconds)''') - channel: str = Field(description='''Channel pattern for wallet spot execution busts''') - data: List[SpotExecutionBust] = Field() + channel: str = Field(description='''Channel pattern for wallet execution busts (spot + perp)''') + data: List[ExecutionBust] = Field() diff --git a/sdk/async_exec_api/cancel_order_request.py b/sdk/async_exec_api/cancel_order_request.py index 1878ff98..1d133967 100644 --- a/sdk/async_exec_api/cancel_order_request.py +++ b/sdk/async_exec_api/cancel_order_request.py @@ -3,13 +3,13 @@ from pydantic import model_serializer, model_validator, BaseModel, Field class CancelOrderRequest(BaseModel): - order_id: Optional[str] = Field(description='''Internal matching engine order ID to cancel. Provide either orderId OR clientOrderId, not both. For spot markets, this is the order ID returned in the CreateOrderResponse.''', default=None, alias='''orderId''') + order_id: Optional[str] = Field(description='''Internal matching engine order ID to cancel. At least one of `orderId` or `clientOrderId` must be provided; if both are supplied the server treats `orderId` as the canonical identifier and `clientOrderId` is ignored. For spot markets, this is the order ID returned in the CreateOrderResponse.''', default=None, alias='''orderId''') client_order_id: Optional[int] = Field(default=None, alias='''clientOrderId''') - account_id: Optional[int] = Field(default=None, alias='''accountId''') - symbol: Optional[str] = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''', default=None) + account_id: int = Field(alias='''accountId''') + symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') signature: str = Field(description='''See signatures section for more details on how to generate.''') - nonce: Optional[str] = Field(description='''See signatures and nonces section for more details. Compulsory for spot orders.''', default=None) - expires_after: Optional[int] = Field(default=None, alias='''expiresAfter''') + nonce: str = Field(description='''See signatures and nonces section for more details.''') + deadline: int = Field() additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) @model_serializer(mode='wrap') @@ -30,13 +30,13 @@ 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', 'account_id', 'symbol', 'signature', 'nonce', 'expires_after', 'additional_properties'] + known_object_properties = ['order_id', 'client_order_id', 'account_id', 'symbol', 'signature', 'nonce', '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', 'accountId', 'symbol', 'signature', 'nonce', 'expiresAfter', 'additionalProperties'] + known_json_properties = ['orderId', 'clientOrderId', 'accountId', 'symbol', 'signature', 'nonce', 'deadline', '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/create_order_request.py b/sdk/async_exec_api/create_order_request.py index 92e09ed7..39fad0df 100644 --- a/sdk/async_exec_api/create_order_request.py +++ b/sdk/async_exec_api/create_order_request.py @@ -5,18 +5,20 @@ from sdk.async_exec_api.time_in_force import TimeInForce class CreateOrderRequest(BaseModel): exchange_id: int = Field(alias='''exchangeId''') - symbol: Optional[str] = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''', default=None) + symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') account_id: int = Field(alias='''accountId''') - is_buy: bool = Field(description='''Whether this is a buy order''', alias='''isBuy''') + is_buy: bool = Field(description='''Whether this is a buy order. Combined with `qty`, determines the signed `OrderDetails.quantity` (int256): positive for buy/long, negative for sell/short.''', alias='''isBuy''') limit_px: str = Field(alias='''limitPx''') qty: Optional[str] = Field(default=None) - order_type: OrderType = Field(description='''Order type, (LIMIT = Limit, TP = Take Profit, SL = Stop Loss)''', alias='''orderType''') - time_in_force: Optional[TimeInForce] = Field(description='''Order time in force (IOC = Immediate or Cancel, GTC = Good Till Cancel)''', default=None, alias='''timeInForce''') + 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='''Whether this is a reduce-only order, exclusively used for LIMIT IOC orders.''', default=None, alias='''reduceOnly''') - signature: str = Field(description='''See signatures and nonces section for more details on how to generate.''') - nonce: str = Field(description='''Order nonce, see signatures and nonces section for more details.''') + 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''') + 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''') + deadline: int = Field() expires_after: Optional[int] = Field(default=None, alias='''expiresAfter''') client_order_id: Optional[int] = Field(default=None, alias='''clientOrderId''') additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) @@ -39,13 +41,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', 'is_buy', 'limit_px', 'qty', 'order_type', 'time_in_force', 'trigger_px', 'reduce_only', 'signature', 'nonce', 'signer_wallet', 'expires_after', 'client_order_id', 'additional_properties'] + known_object_properties = ['exchange_id', 'symbol', 'account_id', 'is_buy', 'limit_px', 'qty', 'order_type', 'time_in_force', 'trigger_px', 'reduce_only', 'post_only', 'signature', 'nonce', 'signer_wallet', 'deadline', 'expires_after', '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 = ['exchangeId', 'symbol', 'accountId', 'isBuy', 'limitPx', 'qty', 'orderType', 'timeInForce', 'triggerPx', 'reduceOnly', 'signature', 'nonce', 'signerWallet', 'expiresAfter', 'clientOrderId', 'additionalProperties'] + known_json_properties = ['exchangeId', 'symbol', 'accountId', 'isBuy', 'limitPx', 'qty', 'orderType', 'timeInForce', 'triggerPx', 'reduceOnly', 'postOnly', 'signature', 'nonce', 'signerWallet', 'deadline', 'expiresAfter', 'clientOrderId', '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/create_order_request_message_payload.py b/sdk/async_exec_api/create_order_request_message_payload.py index 16962de4..2503f5b4 100644 --- a/sdk/async_exec_api/create_order_request_message_payload.py +++ b/sdk/async_exec_api/create_order_request_message_payload.py @@ -6,4 +6,4 @@ class CreateOrderRequestMessagePayload(BaseModel): type: CreateOrderMessageType = Field(description='''Message type for createOrder request and response''') id: str = Field(description='''Client-chosen correlation identifier; must be unique across in-flight requests on the connection.''') - payload: CreateOrderRequest = Field() + payload: CreateOrderRequest = Field(description='''Order creation request. The fields carried here mirror the on-chain `OrderDetails` struct that the client signs via EIP-712. The REST surface keeps `isBuy` + `qty` as separate fields for symmetry with the rest of the API (Order, Execution, Trade schemas); on the signing side the signed `OrderDetails.quantity` is reconstructed as the signed int256 `isBuy ? +qty : -qty`. Two distinct time-related fields are carried and signed: `deadline` (signature validity — enforced by the API at entry) and `expiresAfter` (order lifetime — enforced on-chain at execution). Convention: if `expiresAfter > 0`, clients should ensure `deadline <= expiresAfter` to avoid signing a dead-on-arrival order. See `docs/eip712.md` for the signing algorithm and exact typehash strings.''') diff --git a/sdk/async_exec_api/mass_cancel_request.py b/sdk/async_exec_api/mass_cancel_request.py index e56e23bf..d59b84fc 100644 --- a/sdk/async_exec_api/mass_cancel_request.py +++ b/sdk/async_exec_api/mass_cancel_request.py @@ -7,7 +7,7 @@ class MassCancelRequest(BaseModel): symbol: Optional[str] = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''', default=None) signature: str = Field(description='''See signatures and nonces section for more details on how to generate.''') nonce: str = Field(description='''See signatures and nonces section for more details.''') - expires_after: int = Field(alias='''expiresAfter''') + deadline: int = Field() additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) @model_serializer(mode='wrap') @@ -28,13 +28,13 @@ 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', 'symbol', 'signature', 'nonce', 'expires_after', 'additional_properties'] + known_object_properties = ['account_id', 'symbol', 'signature', 'nonce', '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', 'symbol', 'signature', 'nonce', 'expiresAfter', 'additionalProperties'] + known_json_properties = ['accountId', 'symbol', 'signature', 'nonce', 'deadline', '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/order_type.py b/sdk/async_exec_api/order_type.py index f880c24d..c7b413b7 100644 --- a/sdk/async_exec_api/order_type.py +++ b/sdk/async_exec_api/order_type.py @@ -2,5 +2,5 @@ class OrderType(Enum): LIMIT = "LIMIT" - TP = "TP" - SL = "SL" \ No newline at end of file + STOP_LOSS = "STOP_LOSS" + TAKE_PROFIT = "TAKE_PROFIT" \ No newline at end of file diff --git a/sdk/async_exec_api/time_in_force.py b/sdk/async_exec_api/time_in_force.py index e65ba4af..afd7cc1e 100644 --- a/sdk/async_exec_api/time_in_force.py +++ b/sdk/async_exec_api/time_in_force.py @@ -2,4 +2,5 @@ class TimeInForce(Enum): IOC = "IOC" - GTC = "GTC" \ No newline at end of file + GTC = "GTC" + GTT = "GTT" \ No newline at end of file diff --git a/sdk/open_api/__init__.py b/sdk/open_api/__init__.py index ef662818..273ffd27 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. """ # noqa: E501 -__version__ = "2.2.1.0" +__version__ = "3.0.1.0" # Define package exports __all__ = [ @@ -43,6 +43,8 @@ "CreateOrderResponse", "Depth", "DepthType", + "ExecutionBust", + "ExecutionBustList", "ExecutionType", "FeeTierParameters", "GlobalFeeParameters", @@ -66,8 +68,6 @@ "ServerErrorCode", "Side", "SpotExecution", - "SpotExecutionBust", - "SpotExecutionBustList", "SpotExecutionList", "SpotMarketDefinition", "SpotMarketSummary", @@ -106,6 +106,8 @@ from sdk.open_api.models.create_order_response import CreateOrderResponse as CreateOrderResponse from sdk.open_api.models.depth import Depth as Depth from sdk.open_api.models.depth_type import DepthType as DepthType +from sdk.open_api.models.execution_bust import ExecutionBust as ExecutionBust +from sdk.open_api.models.execution_bust_list import ExecutionBustList as ExecutionBustList from sdk.open_api.models.execution_type import ExecutionType as ExecutionType from sdk.open_api.models.fee_tier_parameters import FeeTierParameters as FeeTierParameters from sdk.open_api.models.global_fee_parameters import GlobalFeeParameters as GlobalFeeParameters @@ -129,8 +131,6 @@ from sdk.open_api.models.server_error_code import ServerErrorCode as ServerErrorCode from sdk.open_api.models.side import Side as Side from sdk.open_api.models.spot_execution import SpotExecution as SpotExecution -from sdk.open_api.models.spot_execution_bust import SpotExecutionBust as SpotExecutionBust -from sdk.open_api.models.spot_execution_bust_list import SpotExecutionBustList as SpotExecutionBustList from sdk.open_api.models.spot_execution_list import SpotExecutionList as SpotExecutionList from sdk.open_api.models.spot_market_definition import SpotMarketDefinition as SpotMarketDefinition from sdk.open_api.models.spot_market_summary import SpotMarketSummary as SpotMarketSummary diff --git a/sdk/open_api/api/market_data_api.py b/sdk/open_api/api/market_data_api.py index e178baa2..18e99470 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -21,10 +21,10 @@ from typing_extensions import Annotated from sdk.open_api.models.candle_history_data import CandleHistoryData from sdk.open_api.models.depth import Depth +from sdk.open_api.models.execution_bust_list import ExecutionBustList from sdk.open_api.models.market_summary import MarketSummary from sdk.open_api.models.perp_execution_list import PerpExecutionList from sdk.open_api.models.price import Price -from sdk.open_api.models.spot_execution_bust_list import SpotExecutionBustList from sdk.open_api.models.spot_execution_list import SpotExecutionList from sdk.open_api.models.spot_market_summary import SpotMarketSummary @@ -51,7 +51,7 @@ async def get_candles( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], resolution: Annotated[StrictStr, Field(description="Candle resolution")], - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -73,7 +73,7 @@ async def get_candles( :type symbol: str :param resolution: Candle resolution (required) :type resolution: str - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -128,7 +128,7 @@ async def get_candles_with_http_info( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], resolution: Annotated[StrictStr, Field(description="Candle resolution")], - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -150,7 +150,7 @@ async def get_candles_with_http_info( :type symbol: str :param resolution: Candle resolution (required) :type resolution: str - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -205,7 +205,7 @@ async def get_candles_without_preload_content( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], resolution: Annotated[StrictStr, Field(description="Candle resolution")], - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -227,7 +227,7 @@ async def get_candles_without_preload_content( :type symbol: str :param resolution: Candle resolution (required) :type resolution: str - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -363,7 +363,7 @@ async def get_market_depth( ) -> Depth: """Get market depth snapshot - Returns an L2 order book snapshot with aggregated price levels for the specified market. + Returns an L2 order book snapshot with aggregated price levels for the specified market. Supports both spot and perp markets. :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str @@ -432,7 +432,7 @@ async def get_market_depth_with_http_info( ) -> ApiResponse[Depth]: """Get market depth snapshot - Returns an L2 order book snapshot with aggregated price levels for the specified market. + Returns an L2 order book snapshot with aggregated price levels for the specified market. Supports both spot and perp markets. :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str @@ -501,7 +501,7 @@ async def get_market_depth_without_preload_content( ) -> RESTResponseType: """Get market depth snapshot - Returns an L2 order book snapshot with aggregated price levels for the specified market. + Returns an L2 order book snapshot with aggregated price levels for the specified market. Supports both spot and perp markets. :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str @@ -611,11 +611,11 @@ def _get_market_depth_serialize( @validate_call - async def get_market_perp_executions( + async def get_market_execution_busts( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -628,16 +628,16 @@ async def get_market_perp_executions( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> PerpExecutionList: - """Get perp executions for market + ) -> ExecutionBustList: + """Get execution busts for market - Returns up to 100 perp executions for a given market. + Returns up to 100 execution busts (failed fills) for a given market, covering both spot and perp markets. Clients can distinguish spot vs perp entries from the `symbol` suffix (`*RUSDPERP` for perp, `*RUSD` for spot). :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -661,7 +661,7 @@ async def get_market_perp_executions( :return: Returns the result object. """ # noqa: E501 - _param = self._get_market_perp_executions_serialize( + _param = self._get_market_execution_busts_serialize( symbol=symbol, start_time=start_time, end_time=end_time, @@ -672,7 +672,7 @@ async def get_market_perp_executions( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "PerpExecutionList", + '200': "ExecutionBustList", '400': "RequestError", '500': "ServerError", } @@ -688,11 +688,11 @@ async def get_market_perp_executions( @validate_call - async def get_market_perp_executions_with_http_info( + async def get_market_execution_busts_with_http_info( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -705,16 +705,16 @@ async def get_market_perp_executions_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[PerpExecutionList]: - """Get perp executions for market + ) -> ApiResponse[ExecutionBustList]: + """Get execution busts for market - Returns up to 100 perp executions for a given market. + Returns up to 100 execution busts (failed fills) for a given market, covering both spot and perp markets. Clients can distinguish spot vs perp entries from the `symbol` suffix (`*RUSDPERP` for perp, `*RUSD` for spot). :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -738,7 +738,7 @@ async def get_market_perp_executions_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._get_market_perp_executions_serialize( + _param = self._get_market_execution_busts_serialize( symbol=symbol, start_time=start_time, end_time=end_time, @@ -749,7 +749,7 @@ async def get_market_perp_executions_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "PerpExecutionList", + '200': "ExecutionBustList", '400': "RequestError", '500': "ServerError", } @@ -765,11 +765,11 @@ async def get_market_perp_executions_with_http_info( @validate_call - async def get_market_perp_executions_without_preload_content( + async def get_market_execution_busts_without_preload_content( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -783,15 +783,15 @@ async def get_market_perp_executions_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get perp executions for market + """Get execution busts for market - Returns up to 100 perp executions for a given market. + Returns up to 100 execution busts (failed fills) for a given market, covering both spot and perp markets. Clients can distinguish spot vs perp entries from the `symbol` suffix (`*RUSDPERP` for perp, `*RUSD` for spot). :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -815,7 +815,7 @@ async def get_market_perp_executions_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._get_market_perp_executions_serialize( + _param = self._get_market_execution_busts_serialize( symbol=symbol, start_time=start_time, end_time=end_time, @@ -826,7 +826,7 @@ async def get_market_perp_executions_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "PerpExecutionList", + '200': "ExecutionBustList", '400': "RequestError", '500': "ServerError", } @@ -837,7 +837,7 @@ async def get_market_perp_executions_without_preload_content( return response_data.response - def _get_market_perp_executions_serialize( + def _get_market_execution_busts_serialize( self, symbol, start_time, @@ -894,7 +894,7 @@ def _get_market_perp_executions_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/market/{symbol}/perpExecutions', + resource_path='/market/{symbol}/executionBusts', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -911,11 +911,12 @@ def _get_market_perp_executions_serialize( @validate_call - async def get_market_spot_execution_busts( + async def get_market_perp_executions( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, + type: Annotated[Optional[StrictStr], Field(description="Filter perp executions by type. Omit to return executions of all types.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -928,17 +929,19 @@ async def get_market_spot_execution_busts( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> SpotExecutionBustList: - """Get spot execution busts for market + ) -> PerpExecutionList: + """Get perp executions for market - Returns up to 100 spot execution busts (failed spot fills) for a given market. + Returns up to 100 perp executions for a given market. :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int + :param type: Filter perp executions by type. Omit to return executions of all types. + :type type: str :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 @@ -961,10 +964,11 @@ async def get_market_spot_execution_busts( :return: Returns the result object. """ # noqa: E501 - _param = self._get_market_spot_execution_busts_serialize( + _param = self._get_market_perp_executions_serialize( symbol=symbol, start_time=start_time, end_time=end_time, + type=type, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -972,7 +976,7 @@ async def get_market_spot_execution_busts( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "SpotExecutionBustList", + '200': "PerpExecutionList", '400': "RequestError", '500': "ServerError", } @@ -988,11 +992,12 @@ async def get_market_spot_execution_busts( @validate_call - async def get_market_spot_execution_busts_with_http_info( + async def get_market_perp_executions_with_http_info( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, + type: Annotated[Optional[StrictStr], Field(description="Filter perp executions by type. Omit to return executions of all types.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1005,17 +1010,19 @@ async def get_market_spot_execution_busts_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[SpotExecutionBustList]: - """Get spot execution busts for market + ) -> ApiResponse[PerpExecutionList]: + """Get perp executions for market - Returns up to 100 spot execution busts (failed spot fills) for a given market. + Returns up to 100 perp executions for a given market. :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int + :param type: Filter perp executions by type. Omit to return executions of all types. + :type type: str :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 @@ -1038,10 +1045,11 @@ async def get_market_spot_execution_busts_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._get_market_spot_execution_busts_serialize( + _param = self._get_market_perp_executions_serialize( symbol=symbol, start_time=start_time, end_time=end_time, + type=type, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1049,7 +1057,7 @@ async def get_market_spot_execution_busts_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "SpotExecutionBustList", + '200': "PerpExecutionList", '400': "RequestError", '500': "ServerError", } @@ -1065,11 +1073,12 @@ async def get_market_spot_execution_busts_with_http_info( @validate_call - async def get_market_spot_execution_busts_without_preload_content( + async def get_market_perp_executions_without_preload_content( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, + type: Annotated[Optional[StrictStr], Field(description="Filter perp executions by type. Omit to return executions of all types.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1083,16 +1092,18 @@ async def get_market_spot_execution_busts_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get spot execution busts for market + """Get perp executions for market - Returns up to 100 spot execution busts (failed spot fills) for a given market. + Returns up to 100 perp executions for a given market. :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int + :param type: Filter perp executions by type. Omit to return executions of all types. + :type type: str :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 @@ -1115,10 +1126,11 @@ async def get_market_spot_execution_busts_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._get_market_spot_execution_busts_serialize( + _param = self._get_market_perp_executions_serialize( symbol=symbol, start_time=start_time, end_time=end_time, + type=type, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1126,7 +1138,7 @@ async def get_market_spot_execution_busts_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "SpotExecutionBustList", + '200': "PerpExecutionList", '400': "RequestError", '500': "ServerError", } @@ -1137,11 +1149,12 @@ async def get_market_spot_execution_busts_without_preload_content( return response_data.response - def _get_market_spot_execution_busts_serialize( + def _get_market_perp_executions_serialize( self, symbol, start_time, end_time, + type, _request_auth, _content_type, _headers, @@ -1174,6 +1187,10 @@ def _get_market_spot_execution_busts_serialize( _query_params.append(('endTime', end_time)) + if type is not None: + + _query_params.append(('type', type)) + # process the header parameters # process the form parameters # process the body parameter @@ -1194,7 +1211,7 @@ def _get_market_spot_execution_busts_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/market/{symbol}/spotExecutionBusts', + resource_path='/market/{symbol}/perpExecutions', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1214,8 +1231,8 @@ def _get_market_spot_execution_busts_serialize( async def get_market_spot_executions( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1235,9 +1252,9 @@ async def get_market_spot_executions( :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -1291,8 +1308,8 @@ async def get_market_spot_executions( async def get_market_spot_executions_with_http_info( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1312,9 +1329,9 @@ async def get_market_spot_executions_with_http_info( :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -1368,8 +1385,8 @@ async def get_market_spot_executions_with_http_info( async def get_market_spot_executions_without_preload_content( self, symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1389,9 +1406,9 @@ async def get_market_spot_executions_without_preload_content( :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -1527,9 +1544,9 @@ async def get_market_summary( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> MarketSummary: - """Get market summary + """(Deprecated) Get market summary - Statistics and throttled data for a specific market. Recalculated every 0.5s + Deprecated: use `/perpMarket/{symbol}/summary` instead. Statistics and throttled data for a specific market. Recalculated every 0.5s. This un-prefixed route still works but will be removed once integrators have migrated to the `perp*` naming. :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str @@ -1554,6 +1571,7 @@ async def get_market_summary( :type _host_index: int, optional :return: Returns the result object. """ # noqa: E501 + warnings.warn("GET /market/{symbol}/summary is deprecated.", DeprecationWarning) _param = self._get_market_summary_serialize( symbol=symbol, @@ -1596,9 +1614,9 @@ async def get_market_summary_with_http_info( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> ApiResponse[MarketSummary]: - """Get market summary + """(Deprecated) Get market summary - Statistics and throttled data for a specific market. Recalculated every 0.5s + Deprecated: use `/perpMarket/{symbol}/summary` instead. Statistics and throttled data for a specific market. Recalculated every 0.5s. This un-prefixed route still works but will be removed once integrators have migrated to the `perp*` naming. :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str @@ -1623,6 +1641,7 @@ async def get_market_summary_with_http_info( :type _host_index: int, optional :return: Returns the result object. """ # noqa: E501 + warnings.warn("GET /market/{symbol}/summary is deprecated.", DeprecationWarning) _param = self._get_market_summary_serialize( symbol=symbol, @@ -1665,9 +1684,9 @@ async def get_market_summary_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get market summary + """(Deprecated) Get market summary - Statistics and throttled data for a specific market. Recalculated every 0.5s + Deprecated: use `/perpMarket/{symbol}/summary` instead. Statistics and throttled data for a specific market. Recalculated every 0.5s. This un-prefixed route still works but will be removed once integrators have migrated to the `perp*` naming. :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) :type symbol: str @@ -1692,6 +1711,7 @@ async def get_market_summary_without_preload_content( :type _host_index: int, optional :return: Returns the result object. """ # noqa: E501 + warnings.warn("GET /market/{symbol}/summary is deprecated.", DeprecationWarning) _param = self._get_market_summary_serialize( symbol=symbol, @@ -1792,9 +1812,9 @@ async def get_markets_summary( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> List[MarketSummary]: - """Get market summaries + """(Deprecated) Get market summaries - Statistics and throttled market data for all markets. Recalculated every 0.5s + Deprecated: use `/perpMarkets/summary` instead. Statistics and throttled market data for all markets. Recalculated every 0.5s. 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 @@ -1817,6 +1837,7 @@ async def get_markets_summary( :type _host_index: int, optional :return: Returns the result object. """ # noqa: E501 + warnings.warn("GET /markets/summary is deprecated.", DeprecationWarning) _param = self._get_markets_summary_serialize( _request_auth=_request_auth, @@ -1857,9 +1878,9 @@ async def get_markets_summary_with_http_info( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> ApiResponse[List[MarketSummary]]: - """Get market summaries + """(Deprecated) Get market summaries - Statistics and throttled market data for all markets. Recalculated every 0.5s + Deprecated: use `/perpMarkets/summary` instead. Statistics and throttled market data for all markets. Recalculated every 0.5s. 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 @@ -1882,6 +1903,7 @@ async def get_markets_summary_with_http_info( :type _host_index: int, optional :return: Returns the result object. """ # noqa: E501 + warnings.warn("GET /markets/summary is deprecated.", DeprecationWarning) _param = self._get_markets_summary_serialize( _request_auth=_request_auth, @@ -1922,9 +1944,9 @@ async def get_markets_summary_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get market summaries + """(Deprecated) Get market summaries - Statistics and throttled market data for all markets. Recalculated every 0.5s + Deprecated: use `/perpMarkets/summary` instead. Statistics and throttled market data for all markets. Recalculated every 0.5s. 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 @@ -1947,6 +1969,7 @@ async def get_markets_summary_without_preload_content( :type _host_index: int, optional :return: Returns the result object. """ # noqa: E501 + warnings.warn("GET /markets/summary is deprecated.", DeprecationWarning) _param = self._get_markets_summary_serialize( _request_auth=_request_auth, @@ -2027,6 +2050,523 @@ def _get_markets_summary_serialize( + @validate_call + async def get_perp_market_summary( + self, + symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], + _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, + ) -> MarketSummary: + """Get perp market summary + + Alias of `/market/{symbol}/summary`, mirroring the `/spotMarket/{symbol}/summary` naming. Statistics and throttled data for a specific perp market. Recalculated every 0.5s + + :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) + :type symbol: str + :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._get_perp_market_summary_serialize( + symbol=symbol, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "MarketSummary", + '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_perp_market_summary_with_http_info( + self, + symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], + _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[MarketSummary]: + """Get perp market summary + + Alias of `/market/{symbol}/summary`, mirroring the `/spotMarket/{symbol}/summary` naming. Statistics and throttled data for a specific perp market. Recalculated every 0.5s + + :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) + :type symbol: str + :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._get_perp_market_summary_serialize( + symbol=symbol, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "MarketSummary", + '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_perp_market_summary_without_preload_content( + self, + symbol: Annotated[str, Field(strict=True, description="Trading symbol (e.g., BTCRUSDPERP)")], + _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: + """Get perp market summary + + Alias of `/market/{symbol}/summary`, mirroring the `/spotMarket/{symbol}/summary` naming. Statistics and throttled data for a specific perp market. Recalculated every 0.5s + + :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) + :type symbol: str + :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._get_perp_market_summary_serialize( + symbol=symbol, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "MarketSummary", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_perp_market_summary_serialize( + self, + symbol, + _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 + if symbol is not None: + _path_params['symbol'] = symbol + # 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='/perpMarket/{symbol}/summary', + 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_markets_summary( + 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[MarketSummary]: + """Get perp market summaries + + Alias of `/markets/summary`, mirroring the `/spotMarkets/summary` naming. Statistics and throttled market data for all perp markets. Recalculated every 0.5s + + :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._get_perp_markets_summary_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "List[MarketSummary]", + '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_perp_markets_summary_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[MarketSummary]]: + """Get perp market summaries + + Alias of `/markets/summary`, mirroring the `/spotMarkets/summary` naming. Statistics and throttled market data for all perp markets. Recalculated every 0.5s + + :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._get_perp_markets_summary_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "List[MarketSummary]", + '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_perp_markets_summary_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: + """Get perp market summaries + + Alias of `/markets/summary`, mirroring the `/spotMarkets/summary` naming. Statistics and throttled market data for all perp markets. Recalculated every 0.5s + + :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._get_perp_markets_summary_serialize( + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "List[MarketSummary]", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_perp_markets_summary_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='/perpMarkets/summary', + 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_price( self, diff --git a/sdk/open_api/api/order_entry_api.py b/sdk/open_api/api/order_entry_api.py index 4a30cb85..f8cc5afa 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -61,7 +61,7 @@ async def cancel_all( ) -> MassCancelResponse: """Cancel all orders - Cancel all orders matching the specified filters (mass cancel) + Cancel all orders matching the specified filters (mass cancel). Supports both spot and perp markets. :param mass_cancel_request: :type mass_cancel_request: MassCancelRequest @@ -130,7 +130,7 @@ async def cancel_all_with_http_info( ) -> ApiResponse[MassCancelResponse]: """Cancel all orders - Cancel all orders matching the specified filters (mass cancel) + Cancel all orders matching the specified filters (mass cancel). Supports both spot and perp markets. :param mass_cancel_request: :type mass_cancel_request: MassCancelRequest @@ -199,7 +199,7 @@ async def cancel_all_without_preload_content( ) -> RESTResponseType: """Cancel all orders - Cancel all orders matching the specified filters (mass cancel) + Cancel all orders matching the specified filters (mass cancel). Supports both spot and perp markets. :param mass_cancel_request: :type mass_cancel_request: MassCancelRequest @@ -340,7 +340,7 @@ async def cancel_order( ) -> CancelOrderResponse: """Cancel order - Cancel an existing 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. :param cancel_order_request: (required) :type cancel_order_request: CancelOrderRequest @@ -409,7 +409,7 @@ async def cancel_order_with_http_info( ) -> ApiResponse[CancelOrderResponse]: """Cancel order - Cancel an existing 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. :param cancel_order_request: (required) :type cancel_order_request: CancelOrderRequest @@ -478,7 +478,7 @@ async def cancel_order_without_preload_content( ) -> RESTResponseType: """Cancel order - Cancel an existing 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. :param cancel_order_request: (required) :type cancel_order_request: CancelOrderRequest @@ -619,7 +619,7 @@ async def create_order( ) -> CreateOrderResponse: """Create order - Create a new order (IOC, GTC, SL, TP) + 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. :param create_order_request: (required) :type create_order_request: CreateOrderRequest @@ -688,7 +688,7 @@ async def create_order_with_http_info( ) -> ApiResponse[CreateOrderResponse]: """Create order - Create a new order (IOC, GTC, SL, TP) + 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. :param create_order_request: (required) :type create_order_request: CreateOrderRequest @@ -757,7 +757,7 @@ async def create_order_without_preload_content( ) -> RESTResponseType: """Create order - Create a new order (IOC, GTC, SL, TP) + 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. :param create_order_request: (required) :type create_order_request: CreateOrderRequest diff --git a/sdk/open_api/api/reference_data_api.py b/sdk/open_api/api/reference_data_api.py index c82f203f..bad18000 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -1050,8 +1050,9 @@ async def get_market_definitions( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> List[MarketDefinition]: - """Get market definitions + """(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 @@ -1074,6 +1075,7 @@ async def get_market_definitions( :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, @@ -1114,8 +1116,9 @@ async def get_market_definitions_with_http_info( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> ApiResponse[List[MarketDefinition]]: - """Get market definitions + """(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 @@ -1138,6 +1141,7 @@ async def get_market_definitions_with_http_info( :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, @@ -1178,8 +1182,9 @@ async def get_market_definitions_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get market definitions + """(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 @@ -1202,6 +1207,7 @@ async def get_market_definitions_without_preload_content( :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, @@ -1282,6 +1288,257 @@ def _get_market_definitions_serialize( + @validate_call + async def get_perp_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]: + """Get perp market definitions + + Alias of `/marketDefinitions`, mirroring the `/spotMarketDefinitions` 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 + + _param = self._get_perp_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_perp_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]]: + """Get perp market definitions + + Alias of `/marketDefinitions`, mirroring the `/spotMarketDefinitions` 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 + + _param = self._get_perp_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_perp_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: + """Get perp market definitions + + Alias of `/marketDefinitions`, mirroring the `/spotMarketDefinitions` 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 + + _param = self._get_perp_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_perp_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='/perpMarketDefinitions', + 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_spot_market_definitions( self, diff --git a/sdk/open_api/api/specs_api.py b/sdk/open_api/api/specs_api.py index 4030c9fd..0f924a9b 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 0ced2035..127f1328 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -16,15 +16,15 @@ from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated -from pydantic import Field, field_validator +from pydantic import Field, StrictStr, field_validator from typing import List, Optional from typing_extensions import Annotated from sdk.open_api.models.account import Account from sdk.open_api.models.account_balance import AccountBalance +from sdk.open_api.models.execution_bust_list import ExecutionBustList from sdk.open_api.models.order import Order from sdk.open_api.models.perp_execution_list import PerpExecutionList from sdk.open_api.models.position import Position -from sdk.open_api.models.spot_execution_bust_list import SpotExecutionBustList from sdk.open_api.models.spot_execution_list import SpotExecutionList from sdk.open_api.models.wallet_configuration import WalletConfiguration @@ -842,9 +842,11 @@ def _get_wallet_configuration_serialize( @validate_call - async def get_wallet_open_orders( + async def get_wallet_execution_busts( self, address: Annotated[str, Field(strict=True)], + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -857,13 +859,17 @@ async def get_wallet_open_orders( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> List[Order]: - """Get wallet open orders + ) -> ExecutionBustList: + """Get wallet execution busts - Returns all pending orders for a wallet. + Returns up to 100 execution busts (failed fills) for a given wallet, covering both spot and perp markets. :param address: (required) :type address: str + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. + :type start_time: int + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. + :type end_time: int :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 @@ -886,8 +892,10 @@ async def get_wallet_open_orders( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_open_orders_serialize( + _param = self._get_wallet_execution_busts_serialize( address=address, + start_time=start_time, + end_time=end_time, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -895,7 +903,7 @@ async def get_wallet_open_orders( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "List[Order]", + '200': "ExecutionBustList", '400': "RequestError", '500': "ServerError", } @@ -911,9 +919,11 @@ async def get_wallet_open_orders( @validate_call - async def get_wallet_open_orders_with_http_info( + async def get_wallet_execution_busts_with_http_info( self, address: Annotated[str, Field(strict=True)], + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -926,13 +936,17 @@ async def get_wallet_open_orders_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[List[Order]]: - """Get wallet open orders + ) -> ApiResponse[ExecutionBustList]: + """Get wallet execution busts - Returns all pending orders for a wallet. + Returns up to 100 execution busts (failed fills) for a given wallet, covering both spot and perp markets. :param address: (required) :type address: str + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. + :type start_time: int + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. + :type end_time: int :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 @@ -955,8 +969,10 @@ async def get_wallet_open_orders_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_open_orders_serialize( + _param = self._get_wallet_execution_busts_serialize( address=address, + start_time=start_time, + end_time=end_time, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -964,7 +980,7 @@ async def get_wallet_open_orders_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "List[Order]", + '200': "ExecutionBustList", '400': "RequestError", '500': "ServerError", } @@ -980,9 +996,11 @@ async def get_wallet_open_orders_with_http_info( @validate_call - async def get_wallet_open_orders_without_preload_content( + async def get_wallet_execution_busts_without_preload_content( self, address: Annotated[str, Field(strict=True)], + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -996,12 +1014,16 @@ async def get_wallet_open_orders_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get wallet open orders + """Get wallet execution busts - Returns all pending orders for a wallet. + Returns up to 100 execution busts (failed fills) for a given wallet, covering both spot and perp markets. :param address: (required) :type address: str + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. + :type start_time: int + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. + :type end_time: int :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 @@ -1024,8 +1046,10 @@ async def get_wallet_open_orders_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_open_orders_serialize( + _param = self._get_wallet_execution_busts_serialize( address=address, + start_time=start_time, + end_time=end_time, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1033,7 +1057,7 @@ async def get_wallet_open_orders_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "List[Order]", + '200': "ExecutionBustList", '400': "RequestError", '500': "ServerError", } @@ -1044,9 +1068,11 @@ async def get_wallet_open_orders_without_preload_content( return response_data.response - def _get_wallet_open_orders_serialize( + def _get_wallet_execution_busts_serialize( self, address, + start_time, + end_time, _request_auth, _content_type, _headers, @@ -1071,6 +1097,14 @@ def _get_wallet_open_orders_serialize( if address is not None: _path_params['address'] = address # process the query parameters + if start_time is not None: + + _query_params.append(('startTime', start_time)) + + if end_time is not None: + + _query_params.append(('endTime', end_time)) + # process the header parameters # process the form parameters # process the body parameter @@ -1091,7 +1125,7 @@ def _get_wallet_open_orders_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/wallet/{address}/openOrders', + resource_path='/wallet/{address}/executionBusts', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1108,11 +1142,9 @@ def _get_wallet_open_orders_serialize( @validate_call - async def get_wallet_perp_executions( + async def get_wallet_open_orders( self, address: Annotated[str, Field(strict=True)], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1125,17 +1157,13 @@ async def get_wallet_perp_executions( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> PerpExecutionList: - """Get wallet perp executions + ) -> List[Order]: + """Get wallet open orders - Returns up to 100 perp executions for a given wallet. + Returns all pending orders for a wallet. :param address: (required) :type address: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) - :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) - :type end_time: int :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 @@ -1158,10 +1186,8 @@ async def get_wallet_perp_executions( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_perp_executions_serialize( + _param = self._get_wallet_open_orders_serialize( address=address, - start_time=start_time, - end_time=end_time, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1169,7 +1195,7 @@ async def get_wallet_perp_executions( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "PerpExecutionList", + '200': "List[Order]", '400': "RequestError", '500': "ServerError", } @@ -1185,11 +1211,9 @@ async def get_wallet_perp_executions( @validate_call - async def get_wallet_perp_executions_with_http_info( + async def get_wallet_open_orders_with_http_info( self, address: Annotated[str, Field(strict=True)], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1202,17 +1226,13 @@ async def get_wallet_perp_executions_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[PerpExecutionList]: - """Get wallet perp executions + ) -> ApiResponse[List[Order]]: + """Get wallet open orders - Returns up to 100 perp executions for a given wallet. + Returns all pending orders for a wallet. :param address: (required) :type address: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) - :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) - :type end_time: int :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 @@ -1235,10 +1255,8 @@ async def get_wallet_perp_executions_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_perp_executions_serialize( + _param = self._get_wallet_open_orders_serialize( address=address, - start_time=start_time, - end_time=end_time, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1246,7 +1264,7 @@ async def get_wallet_perp_executions_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "PerpExecutionList", + '200': "List[Order]", '400': "RequestError", '500': "ServerError", } @@ -1262,11 +1280,9 @@ async def get_wallet_perp_executions_with_http_info( @validate_call - async def get_wallet_perp_executions_without_preload_content( + async def get_wallet_open_orders_without_preload_content( self, address: Annotated[str, Field(strict=True)], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1280,16 +1296,12 @@ async def get_wallet_perp_executions_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get wallet perp executions + """Get wallet open orders - Returns up to 100 perp executions for a given wallet. + Returns all pending orders for a wallet. :param address: (required) :type address: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) - :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) - :type end_time: int :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 @@ -1312,10 +1324,8 @@ async def get_wallet_perp_executions_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_perp_executions_serialize( + _param = self._get_wallet_open_orders_serialize( address=address, - start_time=start_time, - end_time=end_time, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1323,7 +1333,7 @@ async def get_wallet_perp_executions_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "PerpExecutionList", + '200': "List[Order]", '400': "RequestError", '500': "ServerError", } @@ -1334,11 +1344,9 @@ async def get_wallet_perp_executions_without_preload_content( return response_data.response - def _get_wallet_perp_executions_serialize( + def _get_wallet_open_orders_serialize( self, address, - start_time, - end_time, _request_auth, _content_type, _headers, @@ -1363,14 +1371,6 @@ def _get_wallet_perp_executions_serialize( if address is not None: _path_params['address'] = address # process the query parameters - if start_time is not None: - - _query_params.append(('startTime', start_time)) - - if end_time is not None: - - _query_params.append(('endTime', end_time)) - # process the header parameters # process the form parameters # process the body parameter @@ -1391,7 +1391,7 @@ def _get_wallet_perp_executions_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/wallet/{address}/perpExecutions', + resource_path='/wallet/{address}/openOrders', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1408,9 +1408,12 @@ def _get_wallet_perp_executions_serialize( @validate_call - async def get_wallet_positions( + async def get_wallet_perp_executions( self, address: Annotated[str, Field(strict=True)], + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, + type: Annotated[Optional[StrictStr], Field(description="Filter perp executions by type. Omit to return executions of all types.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1423,12 +1426,19 @@ async def get_wallet_positions( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> List[Position]: - """Get wallet positions + ) -> PerpExecutionList: + """Get wallet perp executions + Returns up to 100 perp executions for a given wallet. :param address: (required) :type address: str + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. + :type start_time: int + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. + :type end_time: int + :param type: Filter perp executions by type. Omit to return executions of all types. + :type type: str :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 @@ -1451,8 +1461,11 @@ async def get_wallet_positions( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_positions_serialize( + _param = self._get_wallet_perp_executions_serialize( address=address, + start_time=start_time, + end_time=end_time, + type=type, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1460,7 +1473,7 @@ async def get_wallet_positions( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "List[Position]", + '200': "PerpExecutionList", '400': "RequestError", '500': "ServerError", } @@ -1476,9 +1489,12 @@ async def get_wallet_positions( @validate_call - async def get_wallet_positions_with_http_info( + async def get_wallet_perp_executions_with_http_info( self, address: Annotated[str, Field(strict=True)], + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, + type: Annotated[Optional[StrictStr], Field(description="Filter perp executions by type. Omit to return executions of all types.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1491,12 +1507,19 @@ async def get_wallet_positions_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[List[Position]]: - """Get wallet positions + ) -> ApiResponse[PerpExecutionList]: + """Get wallet perp executions + Returns up to 100 perp executions for a given wallet. :param address: (required) :type address: str + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. + :type start_time: int + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. + :type end_time: int + :param type: Filter perp executions by type. Omit to return executions of all types. + :type type: str :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 @@ -1519,8 +1542,11 @@ async def get_wallet_positions_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_positions_serialize( + _param = self._get_wallet_perp_executions_serialize( address=address, + start_time=start_time, + end_time=end_time, + type=type, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1528,7 +1554,7 @@ async def get_wallet_positions_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "List[Position]", + '200': "PerpExecutionList", '400': "RequestError", '500': "ServerError", } @@ -1544,9 +1570,12 @@ async def get_wallet_positions_with_http_info( @validate_call - async def get_wallet_positions_without_preload_content( + async def get_wallet_perp_executions_without_preload_content( self, address: Annotated[str, Field(strict=True)], + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, + type: Annotated[Optional[StrictStr], Field(description="Filter perp executions by type. Omit to return executions of all types.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1560,11 +1589,18 @@ async def get_wallet_positions_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get wallet positions + """Get wallet perp executions + Returns up to 100 perp executions for a given wallet. :param address: (required) :type address: str + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. + :type start_time: int + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. + :type end_time: int + :param type: Filter perp executions by type. Omit to return executions of all types. + :type type: str :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 @@ -1587,8 +1623,11 @@ async def get_wallet_positions_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_positions_serialize( + _param = self._get_wallet_perp_executions_serialize( address=address, + start_time=start_time, + end_time=end_time, + type=type, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1596,7 +1635,7 @@ async def get_wallet_positions_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "List[Position]", + '200': "PerpExecutionList", '400': "RequestError", '500': "ServerError", } @@ -1607,9 +1646,12 @@ async def get_wallet_positions_without_preload_content( return response_data.response - def _get_wallet_positions_serialize( + def _get_wallet_perp_executions_serialize( self, address, + start_time, + end_time, + type, _request_auth, _content_type, _headers, @@ -1634,6 +1676,18 @@ def _get_wallet_positions_serialize( if address is not None: _path_params['address'] = address # process the query parameters + if start_time is not None: + + _query_params.append(('startTime', start_time)) + + if end_time is not None: + + _query_params.append(('endTime', end_time)) + + if type is not None: + + _query_params.append(('type', type)) + # process the header parameters # process the form parameters # process the body parameter @@ -1654,7 +1708,7 @@ def _get_wallet_positions_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/wallet/{address}/positions', + resource_path='/wallet/{address}/perpExecutions', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1671,11 +1725,9 @@ def _get_wallet_positions_serialize( @validate_call - async def get_wallet_spot_execution_busts( + async def get_wallet_positions( self, address: Annotated[str, Field(strict=True)], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1688,17 +1740,12 @@ async def get_wallet_spot_execution_busts( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> SpotExecutionBustList: - """Get wallet spot execution busts + ) -> List[Position]: + """Get wallet positions - Returns up to 100 spot execution busts (failed spot fills) for a given wallet. :param address: (required) :type address: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) - :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) - :type end_time: int :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 @@ -1721,10 +1768,8 @@ async def get_wallet_spot_execution_busts( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_spot_execution_busts_serialize( + _param = self._get_wallet_positions_serialize( address=address, - start_time=start_time, - end_time=end_time, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1732,7 +1777,7 @@ async def get_wallet_spot_execution_busts( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "SpotExecutionBustList", + '200': "List[Position]", '400': "RequestError", '500': "ServerError", } @@ -1748,11 +1793,9 @@ async def get_wallet_spot_execution_busts( @validate_call - async def get_wallet_spot_execution_busts_with_http_info( + async def get_wallet_positions_with_http_info( self, address: Annotated[str, Field(strict=True)], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1765,17 +1808,12 @@ async def get_wallet_spot_execution_busts_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[SpotExecutionBustList]: - """Get wallet spot execution busts + ) -> ApiResponse[List[Position]]: + """Get wallet positions - Returns up to 100 spot execution busts (failed spot fills) for a given wallet. :param address: (required) :type address: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) - :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) - :type end_time: int :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 @@ -1798,10 +1836,8 @@ async def get_wallet_spot_execution_busts_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_spot_execution_busts_serialize( + _param = self._get_wallet_positions_serialize( address=address, - start_time=start_time, - end_time=end_time, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1809,7 +1845,7 @@ async def get_wallet_spot_execution_busts_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "SpotExecutionBustList", + '200': "List[Position]", '400': "RequestError", '500': "ServerError", } @@ -1825,11 +1861,9 @@ async def get_wallet_spot_execution_busts_with_http_info( @validate_call - async def get_wallet_spot_execution_busts_without_preload_content( + async def get_wallet_positions_without_preload_content( self, address: Annotated[str, Field(strict=True)], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1843,16 +1877,11 @@ async def get_wallet_spot_execution_busts_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get wallet spot execution busts + """Get wallet positions - Returns up to 100 spot execution busts (failed spot fills) for a given wallet. :param address: (required) :type address: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) - :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) - :type end_time: int :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 @@ -1875,10 +1904,8 @@ async def get_wallet_spot_execution_busts_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._get_wallet_spot_execution_busts_serialize( + _param = self._get_wallet_positions_serialize( address=address, - start_time=start_time, - end_time=end_time, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, @@ -1886,7 +1913,7 @@ async def get_wallet_spot_execution_busts_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "SpotExecutionBustList", + '200': "List[Position]", '400': "RequestError", '500': "ServerError", } @@ -1897,11 +1924,9 @@ async def get_wallet_spot_execution_busts_without_preload_content( return response_data.response - def _get_wallet_spot_execution_busts_serialize( + def _get_wallet_positions_serialize( self, address, - start_time, - end_time, _request_auth, _content_type, _headers, @@ -1926,14 +1951,6 @@ def _get_wallet_spot_execution_busts_serialize( if address is not None: _path_params['address'] = address # process the query parameters - if start_time is not None: - - _query_params.append(('startTime', start_time)) - - if end_time is not None: - - _query_params.append(('endTime', end_time)) - # process the header parameters # process the form parameters # process the body parameter @@ -1954,7 +1971,7 @@ def _get_wallet_spot_execution_busts_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/wallet/{address}/spotExecutionBusts', + resource_path='/wallet/{address}/positions', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1974,8 +1991,8 @@ def _get_wallet_spot_execution_busts_serialize( async def get_wallet_spot_executions( self, address: Annotated[str, Field(strict=True)], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -1995,9 +2012,9 @@ async def get_wallet_spot_executions( :param address: (required) :type address: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -2051,8 +2068,8 @@ async def get_wallet_spot_executions( async def get_wallet_spot_executions_with_http_info( self, address: Annotated[str, Field(strict=True)], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -2072,9 +2089,9 @@ async def get_wallet_spot_executions_with_http_info( :param address: (required) :type address: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request @@ -2128,8 +2145,8 @@ async def get_wallet_spot_executions_with_http_info( async def get_wallet_spot_executions_without_preload_content( self, address: Annotated[str, Field(strict=True)], - start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this timestamp, in milliseconds (for pagination)")] = None, - end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this timestamp, in milliseconds (for pagination)")] = None, + start_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp.")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page.")] = None, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], @@ -2149,9 +2166,9 @@ async def get_wallet_spot_executions_without_preload_content( :param address: (required) :type address: str - :param start_time: Return results at or after this timestamp, in milliseconds (for pagination) + :param start_time: Return results at or after this time (inclusive lower bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. :type start_time: int - :param end_time: Return results at or before this timestamp, in milliseconds (for pagination) + :param end_time: Return results at or before this time (inclusive upper bound). Millisecond POSIX timestamp, matched against each execution's on-chain block timestamp. Results are returned newest-first and capped at a maximum that varies by endpoint; to page backward through history, pass the oldest timestamp from the previous page. :type end_time: int :param _request_timeout: timeout setting for this request. If one number provided, it will be total request diff --git a/sdk/open_api/api_client.py b/sdk/open_api/api_client.py index cf201c44..ed03825a 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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/2.2.1.0/python' + self.user_agent = 'OpenAPI-Generator/3.0.1.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 c0f54ef4..56f93dd5 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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: 2.2.1\n"\ - "SDK Package Version: 2.2.1.0".\ + "Version of the API: 3.0.1\n"\ + "SDK Package Version: 3.0.1.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 053b9036..4fb0233b 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 63ef0238..769315f3 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -25,6 +25,8 @@ from sdk.open_api.models.create_order_response import CreateOrderResponse from sdk.open_api.models.depth import Depth from sdk.open_api.models.depth_type import DepthType +from sdk.open_api.models.execution_bust import ExecutionBust +from sdk.open_api.models.execution_bust_list import ExecutionBustList from sdk.open_api.models.execution_type import ExecutionType from sdk.open_api.models.fee_tier_parameters import FeeTierParameters from sdk.open_api.models.global_fee_parameters import GlobalFeeParameters @@ -48,8 +50,6 @@ from sdk.open_api.models.server_error_code import ServerErrorCode from sdk.open_api.models.side import Side from sdk.open_api.models.spot_execution import SpotExecution -from sdk.open_api.models.spot_execution_bust import SpotExecutionBust -from sdk.open_api.models.spot_execution_bust_list import SpotExecutionBustList from sdk.open_api.models.spot_execution_list import SpotExecutionList from sdk.open_api.models.spot_market_definition import SpotMarketDefinition from sdk.open_api.models.spot_market_summary import SpotMarketSummary diff --git a/sdk/open_api/models/account.py b/sdk/open_api/models/account.py index 98b6ee1f..51b106c2 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 4279f34b..2748b978 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 965184ce..efcf8cdc 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 6b609815..941506d8 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/cancel_order_request.py b/sdk/open_api/models/cancel_order_request.py index bea90b79..8ac4f6d8 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -27,22 +27,19 @@ class CancelOrderRequest(BaseModel): """ CancelOrderRequest """ # noqa: E501 - order_id: Optional[StrictStr] = Field(default=None, description="Internal matching engine order ID to cancel. Provide either orderId OR clientOrderId, not both. For spot markets, this is the order ID returned in the CreateOrderResponse.", alias="orderId") + order_id: Optional[StrictStr] = Field(default=None, description="Internal matching engine order ID to cancel. At least one of `orderId` or `clientOrderId` must be provided; if both are supplied the server treats `orderId` as the canonical identifier and `clientOrderId` is ignored. For spot markets, this is the order ID returned in the CreateOrderResponse.", alias="orderId") client_order_id: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="clientOrderId") - account_id: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="accountId") - symbol: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") + account_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="accountId") + symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") signature: StrictStr = Field(description="See signatures section for more details on how to generate.") - nonce: Optional[StrictStr] = Field(default=None, description="See signatures and nonces section for more details. Compulsory for spot orders.") - expires_after: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="expiresAfter") + nonce: StrictStr = Field(description="See signatures and nonces section for more details.") + deadline: Annotated[int, Field(strict=True, ge=0)] additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["orderId", "clientOrderId", "accountId", "symbol", "signature", "nonce", "expiresAfter"] + __properties: ClassVar[List[str]] = ["orderId", "clientOrderId", "accountId", "symbol", "signature", "nonce", "deadline"] @field_validator('symbol') def symbol_validate_regular_expression(cls, value): """Validates the regular expression""" - if value is None: - return value - if not re.match(r"^[A-Za-z0-9]+$", value): raise ValueError(r"must validate the regular expression /^[A-Za-z0-9]+$/") return value @@ -111,7 +108,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "symbol": obj.get("symbol"), "signature": obj.get("signature"), "nonce": obj.get("nonce"), - "expiresAfter": obj.get("expiresAfter") + "deadline": obj.get("deadline") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdk/open_api/models/cancel_order_response.py b/sdk/open_api/models/cancel_order_response.py index d2fe5ea2..dc24afac 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 04308a72..e792c7d3 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 ea6c272b..84e34103 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -27,32 +27,31 @@ class CreateOrderRequest(BaseModel): """ - CreateOrderRequest + Order creation request. The fields carried here mirror the on-chain `OrderDetails` struct that the client signs via EIP-712. The REST surface keeps `isBuy` + `qty` as separate fields for symmetry with the rest of the API (Order, Execution, Trade schemas); on the signing side the signed `OrderDetails.quantity` is reconstructed as the signed int256 `isBuy ? +qty : -qty`. Two distinct time-related fields are carried and signed: `deadline` (signature validity — enforced by the API at entry) and `expiresAfter` (order lifetime — enforced on-chain at execution). Convention: if `expiresAfter > 0`, clients should ensure `deadline <= expiresAfter` to avoid signing a dead-on-arrival order. See `docs/eip712.md` for the signing algorithm and exact typehash strings. """ # noqa: E501 exchange_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="exchangeId") - symbol: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") + 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") - is_buy: StrictBool = Field(description="Whether this is a buy order", alias="isBuy") + is_buy: StrictBool = Field(description="Whether this is a buy order. Combined with `qty`, determines the signed `OrderDetails.quantity` (int256): positive for buy/long, negative for sell/short.", alias="isBuy") limit_px: Annotated[str, Field(strict=True)] = Field(alias="limitPx") qty: Optional[Annotated[str, Field(strict=True)]] = None 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="Whether this is a reduce-only order, exclusively used for LIMIT IOC orders.", alias="reduceOnly") - signature: StrictStr = Field(description="See signatures and nonces section for more details on how to generate.") - nonce: StrictStr = Field(description="Order nonce, see signatures and nonces section for more details.") + 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") + 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") + deadline: Annotated[int, Field(strict=True, ge=0)] expires_after: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="expiresAfter") 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]] = ["exchangeId", "symbol", "accountId", "isBuy", "limitPx", "qty", "orderType", "timeInForce", "triggerPx", "reduceOnly", "signature", "nonce", "signerWallet", "expiresAfter", "clientOrderId"] + __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "accountId", "isBuy", "limitPx", "qty", "orderType", "timeInForce", "triggerPx", "reduceOnly", "postOnly", "signature", "nonce", "signerWallet", "deadline", "expiresAfter", "clientOrderId"] @field_validator('symbol') def symbol_validate_regular_expression(cls, value): """Validates the regular expression""" - if value is None: - return value - if not re.match(r"^[A-Za-z0-9]+$", value): raise ValueError(r"must validate the regular expression /^[A-Za-z0-9]+$/") return value @@ -159,9 +158,11 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "timeInForce": obj.get("timeInForce"), "triggerPx": obj.get("triggerPx"), "reduceOnly": obj.get("reduceOnly"), + "postOnly": obj.get("postOnly"), "signature": obj.get("signature"), "nonce": obj.get("nonce"), "signerWallet": obj.get("signerWallet"), + "deadline": obj.get("deadline"), "expiresAfter": obj.get("expiresAfter"), "clientOrderId": obj.get("clientOrderId") }) diff --git a/sdk/open_api/models/create_order_response.py b/sdk/open_api/models/create_order_response.py index a0dc8d04..f9df0795 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 014d6d53..99427398 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 7ccb0764..9d9ce021 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/spot_execution_bust.py b/sdk/open_api/models/execution_bust.py similarity index 94% rename from sdk/open_api/models/spot_execution_bust.py rename to sdk/open_api/models/execution_bust.py index 83b1a1bb..e8c14ac3 100644 --- a/sdk/open_api/models/spot_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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -24,9 +24,9 @@ from typing import Optional, Set from typing_extensions import Self -class SpotExecutionBust(BaseModel): +class ExecutionBust(BaseModel): """ - SpotExecutionBust + ExecutionBust """ # noqa: E501 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") @@ -37,7 +37,7 @@ class SpotExecutionBust(BaseModel): qty: Annotated[str, Field(strict=True)] side: Side price: Annotated[str, Field(strict=True)] - reason: StrictStr = Field(description="Hex-encoded revert reason bytes") + reason: StrictStr = Field(description="Human Readable Reason String (decoded revert reason bytes)") timestamp: Annotated[int, Field(strict=True, ge=0)] sequence_number: Annotated[int, Field(strict=True, ge=0)] = Field(alias="sequenceNumber") additional_properties: Dict[str, Any] = {} @@ -82,7 +82,7 @@ def to_json(self) -> str: @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of SpotExecutionBust from a JSON string""" + """Create an instance of ExecutionBust from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -114,7 +114,7 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of SpotExecutionBust from a dict""" + """Create an instance of ExecutionBust from a dict""" if obj is None: return None diff --git a/sdk/open_api/models/spot_execution_bust_list.py b/sdk/open_api/models/execution_bust_list.py similarity index 87% rename from sdk/open_api/models/spot_execution_bust_list.py rename to sdk/open_api/models/execution_bust_list.py index ca487c6d..9ac9569f 100644 --- a/sdk/open_api/models/spot_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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -19,16 +19,16 @@ from pydantic import BaseModel, ConfigDict from typing import Any, ClassVar, Dict, List +from sdk.open_api.models.execution_bust import ExecutionBust from sdk.open_api.models.pagination_meta import PaginationMeta -from sdk.open_api.models.spot_execution_bust import SpotExecutionBust from typing import Optional, Set from typing_extensions import Self -class SpotExecutionBustList(BaseModel): +class ExecutionBustList(BaseModel): """ - SpotExecutionBustList + ExecutionBustList """ # noqa: E501 - data: List[SpotExecutionBust] + data: List[ExecutionBust] meta: PaginationMeta additional_properties: Dict[str, Any] = {} __properties: ClassVar[List[str]] = ["data", "meta"] @@ -51,7 +51,7 @@ def to_json(self) -> str: @classmethod def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of SpotExecutionBustList from a JSON string""" + """Create an instance of ExecutionBustList from a JSON string""" return cls.from_dict(json.loads(json_str)) def to_dict(self) -> Dict[str, Any]: @@ -93,7 +93,7 @@ def to_dict(self) -> Dict[str, Any]: @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of SpotExecutionBustList from a dict""" + """Create an instance of ExecutionBustList from a dict""" if obj is None: return None @@ -101,7 +101,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: return cls.model_validate(obj) _obj = cls.model_validate({ - "data": [SpotExecutionBust.from_dict(_item) for _item in obj["data"]] if obj.get("data") is not None else None, + "data": [ExecutionBust.from_dict(_item) for _item in obj["data"]] if obj.get("data") is not None else None, "meta": PaginationMeta.from_dict(obj["meta"]) if obj.get("meta") is not None else None }) # store additional fields in additional_properties diff --git a/sdk/open_api/models/execution_type.py b/sdk/open_api/models/execution_type.py index 85463ec4..474942ac 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -29,6 +29,7 @@ class ExecutionType(str, Enum): ORDER_MATCH = 'ORDER_MATCH' LIQUIDATION = 'LIQUIDATION' ADL = 'ADL' + DUST = 'DUST' @classmethod def from_json(cls, json_str: str) -> Self: diff --git a/sdk/open_api/models/fee_tier_parameters.py b/sdk/open_api/models/fee_tier_parameters.py index 4d8bef9d..30b104d2 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 10476bde..afbc4af8 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 a4309d84..c2d7df6e 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 5cace740..14567203 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 f03d877a..a974406e 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 3622e555..6d0eaca7 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -29,20 +29,17 @@ class MarketSummary(BaseModel): """ # noqa: E501 symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") updated_at: Annotated[int, Field(strict=True, ge=0)] = Field(alias="updatedAt") - long_oi_qty: Annotated[str, Field(strict=True)] = Field(alias="longOiQty") - short_oi_qty: Annotated[str, Field(strict=True)] = Field(alias="shortOiQty") oi_qty: Annotated[str, Field(strict=True)] = Field(alias="oiQty") funding_rate: Annotated[str, Field(strict=True)] = Field(alias="fundingRate") long_funding_value: Annotated[str, Field(strict=True)] = Field(alias="longFundingValue") short_funding_value: Annotated[str, Field(strict=True)] = Field(alias="shortFundingValue") - funding_rate_velocity: Annotated[str, Field(strict=True)] = Field(alias="fundingRateVelocity") volume24h: Annotated[str, Field(strict=True)] px_change24h: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="pxChange24h") - throttled_oracle_price: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="throttledOraclePrice") - throttled_pool_price: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="throttledPoolPrice") + mark_price: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="markPrice") + throttled_mid_price: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="throttledMidPrice") prices_updated_at: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="pricesUpdatedAt") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["symbol", "updatedAt", "longOiQty", "shortOiQty", "oiQty", "fundingRate", "longFundingValue", "shortFundingValue", "fundingRateVelocity", "volume24h", "pxChange24h", "throttledOraclePrice", "throttledPoolPrice", "pricesUpdatedAt"] + __properties: ClassVar[List[str]] = ["symbol", "updatedAt", "oiQty", "fundingRate", "longFundingValue", "shortFundingValue", "volume24h", "pxChange24h", "markPrice", "throttledMidPrice", "pricesUpdatedAt"] @field_validator('symbol') def symbol_validate_regular_expression(cls, value): @@ -51,20 +48,6 @@ def symbol_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^[A-Za-z0-9]+$/") return value - @field_validator('long_oi_qty') - def long_oi_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('short_oi_qty') - def short_oi_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('oi_qty') def oi_qty_validate_regular_expression(cls, value): """Validates the regular expression""" @@ -93,13 +76,6 @@ def short_funding_value_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") return value - @field_validator('funding_rate_velocity') - def funding_rate_velocity_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('volume24h') def volume24h_validate_regular_expression(cls, value): """Validates the regular expression""" @@ -117,8 +93,8 @@ def px_change24h_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") return value - @field_validator('throttled_oracle_price') - def throttled_oracle_price_validate_regular_expression(cls, value): + @field_validator('mark_price') + def mark_price_validate_regular_expression(cls, value): """Validates the regular expression""" if value is None: return value @@ -127,8 +103,8 @@ def throttled_oracle_price_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") return value - @field_validator('throttled_pool_price') - def throttled_pool_price_validate_regular_expression(cls, value): + @field_validator('throttled_mid_price') + def throttled_mid_price_validate_regular_expression(cls, value): """Validates the regular expression""" if value is None: return value @@ -197,17 +173,14 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "symbol": obj.get("symbol"), "updatedAt": obj.get("updatedAt"), - "longOiQty": obj.get("longOiQty"), - "shortOiQty": obj.get("shortOiQty"), "oiQty": obj.get("oiQty"), "fundingRate": obj.get("fundingRate"), "longFundingValue": obj.get("longFundingValue"), "shortFundingValue": obj.get("shortFundingValue"), - "fundingRateVelocity": obj.get("fundingRateVelocity"), "volume24h": obj.get("volume24h"), "pxChange24h": obj.get("pxChange24h"), - "throttledOraclePrice": obj.get("throttledOraclePrice"), - "throttledPoolPrice": obj.get("throttledPoolPrice"), + "markPrice": obj.get("markPrice"), + "throttledMidPrice": obj.get("throttledMidPrice"), "pricesUpdatedAt": obj.get("pricesUpdatedAt") }) # store additional fields in additional_properties diff --git a/sdk/open_api/models/mass_cancel_request.py b/sdk/open_api/models/mass_cancel_request.py index 3a2424c4..341ed63f 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -31,9 +31,9 @@ class MassCancelRequest(BaseModel): symbol: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") signature: StrictStr = Field(description="See signatures and nonces section for more details on how to generate.") nonce: StrictStr = Field(description="See signatures and nonces section for more details.") - expires_after: Annotated[int, Field(strict=True, ge=0)] = Field(alias="expiresAfter") + deadline: Annotated[int, Field(strict=True, ge=0)] additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["accountId", "symbol", "signature", "nonce", "expiresAfter"] + __properties: ClassVar[List[str]] = ["accountId", "symbol", "signature", "nonce", "deadline"] @field_validator('symbol') def symbol_validate_regular_expression(cls, value): @@ -107,7 +107,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "symbol": obj.get("symbol"), "signature": obj.get("signature"), "nonce": obj.get("nonce"), - "expiresAfter": obj.get("expiresAfter") + "deadline": obj.get("deadline") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdk/open_api/models/mass_cancel_response.py b/sdk/open_api/models/mass_cancel_response.py index 406e8d9f..288df8f6 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/order.py b/sdk/open_api/models/order.py index 98f47008..e80aac6e 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/order_status.py b/sdk/open_api/models/order_status.py index 4956d4b2..c128aecf 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 44a9e792..ce413c3f 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -20,15 +20,15 @@ class OrderType(str, Enum): """ - Order type, (LIMIT = Limit, TP = Take Profit, SL = Stop Loss) + 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. """ """ allowed enum values """ LIMIT = 'LIMIT' - TP = 'TP' - SL = 'SL' + STOP_LOSS = 'STOP_LOSS' + TAKE_PROFIT = 'TAKE_PROFIT' @classmethod def from_json(cls, json_str: str) -> Self: diff --git a/sdk/open_api/models/pagination_meta.py b/sdk/open_api/models/pagination_meta.py index d886a670..312faca7 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 b84895fd..66ad5354 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -17,7 +17,7 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, field_validator +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.execution_type import ExecutionType @@ -31,20 +31,28 @@ class PerpExecution(BaseModel): """ # noqa: E501 exchange_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="exchangeId") 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") + taker_account_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="takerAccountId") + maker_account_id: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="makerAccountId") + taker_order_id: Optional[StrictStr] = Field(default=None, description="Order ID for the taker. Absent for legacy V2 executions and omitted when not meaningful.", alias="takerOrderId") + maker_order_id: Optional[StrictStr] = Field(default=None, description="Order ID for the maker. Absent for legacy V2 executions and omitted when not meaningful.", alias="makerOrderId") qty: Annotated[str, Field(strict=True)] side: Side price: Annotated[str, Field(strict=True)] - fee: Annotated[str, Field(strict=True)] - opening_fee: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="openingFee") + taker_fee: Annotated[str, Field(strict=True)] = Field(alias="takerFee") + maker_fee: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="makerFee") + taker_opening_fee: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="takerOpeningFee") + maker_opening_fee: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="makerOpeningFee") type: ExecutionType timestamp: Annotated[int, Field(strict=True, ge=0)] sequence_number: Annotated[int, Field(strict=True, ge=0)] = Field(alias="sequenceNumber") - realized_pnl: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="realizedPnl") - price_variation_pnl: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="priceVariationPnl") - funding_pnl: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="fundingPnl") + taker_realized_pnl: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="takerRealizedPnl") + maker_realized_pnl: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="makerRealizedPnl") + taker_price_variation_pnl: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="takerPriceVariationPnl") + maker_price_variation_pnl: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="makerPriceVariationPnl") + taker_funding_pnl: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="takerFundingPnl") + maker_funding_pnl: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="makerFundingPnl") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "accountId", "qty", "side", "price", "fee", "openingFee", "type", "timestamp", "sequenceNumber", "realizedPnl", "priceVariationPnl", "fundingPnl"] + __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "takerAccountId", "makerAccountId", "takerOrderId", "makerOrderId", "qty", "side", "price", "takerFee", "makerFee", "takerOpeningFee", "makerOpeningFee", "type", "timestamp", "sequenceNumber", "takerRealizedPnl", "makerRealizedPnl", "takerPriceVariationPnl", "makerPriceVariationPnl", "takerFundingPnl", "makerFundingPnl"] @field_validator('symbol') def symbol_validate_regular_expression(cls, value): @@ -67,15 +75,15 @@ def price_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") return value - @field_validator('fee') - def fee_validate_regular_expression(cls, value): + @field_validator('taker_fee') + def taker_fee_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('opening_fee') - def opening_fee_validate_regular_expression(cls, value): + @field_validator('maker_fee') + def maker_fee_validate_regular_expression(cls, value): """Validates the regular expression""" if value is None: return value @@ -84,8 +92,8 @@ def opening_fee_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") return value - @field_validator('realized_pnl') - def realized_pnl_validate_regular_expression(cls, value): + @field_validator('taker_opening_fee') + def taker_opening_fee_validate_regular_expression(cls, value): """Validates the regular expression""" if value is None: return value @@ -94,8 +102,8 @@ def realized_pnl_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") return value - @field_validator('price_variation_pnl') - def price_variation_pnl_validate_regular_expression(cls, value): + @field_validator('maker_opening_fee') + def maker_opening_fee_validate_regular_expression(cls, value): """Validates the regular expression""" if value is None: return value @@ -104,8 +112,58 @@ def price_variation_pnl_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") return value - @field_validator('funding_pnl') - def funding_pnl_validate_regular_expression(cls, value): + @field_validator('taker_realized_pnl') + def taker_realized_pnl_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('maker_realized_pnl') + def maker_realized_pnl_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('taker_price_variation_pnl') + def taker_price_variation_pnl_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('maker_price_variation_pnl') + def maker_price_variation_pnl_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('taker_funding_pnl') + def taker_funding_pnl_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('maker_funding_pnl') + def maker_funding_pnl_validate_regular_expression(cls, value): """Validates the regular expression""" if value is None: return value @@ -174,18 +232,26 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "exchangeId": obj.get("exchangeId"), "symbol": obj.get("symbol"), - "accountId": obj.get("accountId"), + "takerAccountId": obj.get("takerAccountId"), + "makerAccountId": obj.get("makerAccountId"), + "takerOrderId": obj.get("takerOrderId"), + "makerOrderId": obj.get("makerOrderId"), "qty": obj.get("qty"), "side": obj.get("side"), "price": obj.get("price"), - "fee": obj.get("fee"), - "openingFee": obj.get("openingFee"), + "takerFee": obj.get("takerFee"), + "makerFee": obj.get("makerFee"), + "takerOpeningFee": obj.get("takerOpeningFee"), + "makerOpeningFee": obj.get("makerOpeningFee"), "type": obj.get("type"), "timestamp": obj.get("timestamp"), "sequenceNumber": obj.get("sequenceNumber"), - "realizedPnl": obj.get("realizedPnl"), - "priceVariationPnl": obj.get("priceVariationPnl"), - "fundingPnl": obj.get("fundingPnl") + "takerRealizedPnl": obj.get("takerRealizedPnl"), + "makerRealizedPnl": obj.get("makerRealizedPnl"), + "takerPriceVariationPnl": obj.get("takerPriceVariationPnl"), + "makerPriceVariationPnl": obj.get("makerPriceVariationPnl"), + "takerFundingPnl": obj.get("takerFundingPnl"), + "makerFundingPnl": obj.get("makerFundingPnl") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdk/open_api/models/perp_execution_list.py b/sdk/open_api/models/perp_execution_list.py index 3c6d66ab..58de3058 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 f183f73b..e02d0c1d 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 7732190f..8f9409db 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 1f7785a3..e9ab8609 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 89209cf1..e9b4c345 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/server_error.py b/sdk/open_api/models/server_error.py index 4ae78aac..d1f5423d 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 7b1f6501..ab339ce0 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 6e8f2bb7..8854e226 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 204f348e..f3ccbb22 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 35b230cb..b00d1436 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 5ca68d96..ab6d6681 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 20a269b6..11d0bba8 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 311bbeba..052c04f3 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 7aa88d81..6538fe8d 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -20,7 +20,7 @@ class TimeInForce(str, Enum): """ - Order time in force (IOC = Immediate or Cancel, GTC = Good Till Cancel) + Order time in force (IOC = Immediate or Cancel, GTC = Good Till Cancel, GTT = Good Till Time) """ """ @@ -28,6 +28,7 @@ class TimeInForce(str, Enum): """ IOC = 'IOC' GTC = 'GTC' + GTT = 'GTT' @classmethod def from_json(cls, json_str: str) -> Self: diff --git a/sdk/open_api/models/wallet_configuration.py b/sdk/open_api/models/wallet_configuration.py index 175499e1..ff31e19b 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 29e2772c..c91acb5e 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: 2.2.1 + The version of the OpenAPI document: 3.0.1 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 06e9477e..09968006 100644 --- a/sdk/reya_rest_api/auth/signatures.py +++ b/sdk/reya_rest_api/auth/signatures.py @@ -1,30 +1,63 @@ """ Signature generation utilities for Reya Trading API authentication. -This module provides tools for creating EIP-712 signatures for order creation -and message signatures for order cancellation. +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. """ -import json from decimal import Decimal +from enum import IntEnum -from eth_abi import encode from eth_account import Account -from eth_account.messages import encode_defunct from sdk.reya_rest_api.config import TradingConfig +class OrderTypeInt(IntEnum): + """On-chain `OrderDetails.orderType` values. Mirrors the API string enum + but encodes the uint8 expected by the EIP-712 typed data.""" + + LIMIT = 0 + STOP_LOSS = 1 + TAKE_PROFIT = 2 + + +class TimeInForceInt(IntEnum): + """On-chain `OrderDetails.timeInForce` values.""" + + GTC = 0 + IOC = 1 + GTT = 2 + + +# EIP-712 `OrderDetails` member list. Field order MUST match the on-chain +# OrderDetails typehash byte-for-byte (the struct hash is order-sensitive). +# `postOnly` sits immediately after `reduceOnly` (14 fields). The golden-vector +# test in tests/parity/test_order_details_golden.py pins the resulting struct +# hash to the canonical on-chain value, so accidental drift is caught. +_ORDER_DETAILS_TYPE: list[dict[str, str]] = [ + {"name": "accountId", "type": "uint128"}, + {"name": "marketId", "type": "uint128"}, + {"name": "exchangeId", "type": "uint128"}, + {"name": "orderType", "type": "uint8"}, + {"name": "quantity", "type": "int256"}, + {"name": "limitPrice", "type": "uint256"}, + {"name": "triggerPrice", "type": "uint256"}, + {"name": "timeInForce", "type": "uint8"}, + {"name": "clientOrderId", "type": "uint64"}, + {"name": "reduceOnly", "type": "bool"}, + {"name": "postOnly", "type": "bool"}, + {"name": "expiresAfter", "type": "uint256"}, + {"name": "signer", "type": "address"}, + {"name": "nonce", "type": "uint256"}, +] + + class SignatureGenerator: - """Generate signatures for Reya Trading API requests.""" + """Generate EIP-712 signatures for Reya Trading API requests.""" def __init__(self, config: TradingConfig): - """ - Initialize the signature generator with configuration. - - Args: - config: Trading API configuration - """ self.config = config self._private_key = config.private_key self._chain_id = config.chain_id @@ -32,124 +65,67 @@ def __init__(self, config: TradingConfig): if not self._private_key: raise ValueError("Private key is required for signing") - # Calculate signer wallet address from private key self._signer_wallet_address: str = str(Account.from_key(self._private_key).address) @property def signer_wallet_address(self) -> str: - """Get the signer wallet address derived from the private key.""" return self._signer_wallet_address - def scale(self, decimals: int): - """Returns a function that scales a number (str, int, float, or Decimal) to an integer.""" - factor = 10**decimals - - def _scale(value): - return int(Decimal(value) * factor) - - return _scale - - def encode_inputs_limit_order( - self, - is_buy: bool, - limit_px: Decimal, - qty: Decimal, - ) -> str: - scaler = self.scale(18) - - # Negate qty if it's a sell order - signed_qty = qty if is_buy else -qty - - encoded = encode(["int256", "uint256"], [scaler(signed_qty), scaler(limit_px)]) - return encoded.hex() if encoded.hex().startswith("0x") else f"0x{encoded.hex()}" + @staticmethod + def _scale_e18(value) -> int: + """Scale a decimal/string/int/float to an E18 integer.""" + return int(Decimal(str(value)) * (10**18)) - def encode_inputs_trigger_order( - self, - is_buy: bool, - trigger_px: Decimal, - limit_px: Decimal, - ) -> str: - scaler = self.scale(18) + @property + def _domain(self) -> dict: + """EIP-712 domain shared by Order, OrderCancel, MassCancel. - encoded = encode( - ["bool", "uint256", "uint256"], - [bool(is_buy), scaler(trigger_px), scaler(limit_px)], - ) - return encoded.hex() if encoded.hex().startswith("0x") else f"0x{encoded.hex()}" + Note: chainId is intentionally absent from the domain — it travels in + the envelope as `verifyingChainId` so signatures stay portable across + forks where the domain separator would diverge.""" + return { + "name": "Reya", + "version": "1", + "verifyingContract": self.config.default_orders_gateway_address, + } - def create_orders_gateway_nonce( - self, - account_id: int, - market_id: int, - timestamp_ms: int, - ) -> int: - """Create a nonce for Orders Gateway orders.""" - # Validate the input ranges - if market_id < 0 or market_id >= 2**32: - raise ValueError("marketId is out of range") - if account_id < 0 or account_id >= 2**128: - raise ValueError("accountId is out of range") - if timestamp_ms < 0 or timestamp_ms >= 2**64: - raise ValueError("timestamp is out of range") - - hash_uint256 = (account_id << 98) | (timestamp_ms << 32) | market_id - - return hash_uint256 - - def sign_raw_order( + def sign_order( self, account_id: int, market_id: int, exchange_id: int, - counterparty_account_ids: list, order_type: int, - inputs: str, # hex-encoded ABI data - deadline: int, + is_buy: bool, + qty: Decimal, + limit_price: Decimal, + trigger_price: Decimal, + time_in_force: int, + client_order_id: int, + reduce_only: bool, + expires_after: int, nonce: int, + deadline: int, + post_only: bool = False, ) -> str: + """Sign an Order envelope per docs/eip712.md. + + Reconstructs the signed `OrderDetails.quantity` (int256) from + `is_buy` + unsigned `qty` as `is_buy ? +qty : -qty`. A negative `qty` + would silently flip the trade direction, so reject it up front. """ - Sign an Orders Gateway order using EIP-712. - - Args: - account_id: The Reya account ID - market_id: The market ID for this order - exchange_id: Exchange ID (usually 2) - counterparty_account_ids: List of counterparty account IDs - order_type: Order type enum value - inputs: ABI-encoded order inputs - deadline: Signature expiration timestamp - nonce: The nonce to use for this order (must match the nonce passed to the API) - - Returns: - Hex-encoded signature - """ - # Define EIP-712 domain - domain = { - "name": "Reya", - "version": "1", - "verifyingContract": self.config.default_orders_gateway_address, - } + if qty < 0: + raise ValueError(f"sign_order requires unsigned qty (got {qty}); use is_buy to set direction") + signed_qty = qty if is_buy else -qty - # Define the message types for EIP-712 (conditional order format) types = { - "ConditionalOrder": [ + "Order": [ {"name": "verifyingChainId", "type": "uint256"}, {"name": "deadline", "type": "uint256"}, - {"name": "order", "type": "ConditionalOrderDetails"}, - ], - "ConditionalOrderDetails": [ - {"name": "accountId", "type": "uint128"}, - {"name": "marketId", "type": "uint128"}, - {"name": "exchangeId", "type": "uint128"}, - {"name": "counterpartyAccountIds", "type": "uint128[]"}, - {"name": "orderType", "type": "uint8"}, - {"name": "inputs", "type": "bytes"}, - {"name": "signer", "type": "address"}, - {"name": "nonce", "type": "uint256"}, + {"name": "order", "type": "OrderDetails"}, ], + "OrderDetails": _ORDER_DETAILS_TYPE, } - # Create the message to sign message = { "verifyingChainId": self._chain_id, "deadline": deadline, @@ -157,56 +133,24 @@ def sign_raw_order( "accountId": account_id, "marketId": market_id, "exchangeId": exchange_id, - "counterpartyAccountIds": counterparty_account_ids, "orderType": order_type, - "inputs": inputs, + "quantity": self._scale_e18(signed_qty), + "limitPrice": self._scale_e18(limit_price), + "triggerPrice": self._scale_e18(trigger_price), + "timeInForce": time_in_force, + "clientOrderId": client_order_id, + "reduceOnly": reduce_only, + "postOnly": post_only, + "expiresAfter": expires_after, "signer": self._signer_wallet_address, "nonce": nonce, }, } - # Sign the message using the correct eth-account format - signed_message = Account.sign_typed_data(self._private_key, domain, types, message) - - return ( - signed_message.signature.hex() - if signed_message.signature.hex().startswith("0x") - else f"0x{signed_message.signature.hex()}" - ) - - def sign_cancel_order_perps(self, order_id: str) -> str: - """ - Sign an order cancellation message using personal_sign. + signed_message = Account.sign_typed_data(self._private_key, self._domain, types, message) + return _to_hex_signature(signed_message.signature.hex()) - Args: - order_id: ID of the order to cancel - - Returns: - Hex-encoded signature - """ - # Create cancellation message - cancel_message = { - "orderId": order_id, - "status": "cancelled", - "actionType": "changeStatus", - } - - # Convert to JSON string - message_str = json.dumps(cancel_message, separators=(",", ":")) - - # Prepare an EIP-191 message - signable_message = encode_defunct(text=message_str) - - # Sign the message - signed_message = Account.sign_message(signable_message, private_key=self._private_key) - - return ( - signed_message.signature.hex() - if signed_message.signature.hex().startswith("0x") - else f"0x{signed_message.signature.hex()}" - ) - - def sign_cancel_order_spot( + def sign_cancel_order( self, account_id: int, market_id: int, @@ -215,31 +159,11 @@ def sign_cancel_order_spot( nonce: int, deadline: int, ) -> str: - """ - Sign an order cancellation message using EIP-712 (for SPOT orders). + """Sign an OrderCancel envelope (matching-engine layer). - This method generates an EIP-712 signature for cancelling a specific order. - For SPOT market orders, both orderId and clientOrderId must be provided. - - Args: - account_id: The Reya account ID - market_id: The market ID for this order - order_id: Internal matching engine order ID to cancel - client_order_id: Client-provided order ID - nonce: Unique nonce for this cancellation (microsecond timestamp) - deadline: Signature expiration timestamp (milliseconds) - - Returns: - Hex-encoded signature + Works for both spot and perp markets. `order_id` and `client_order_id` + are mutually exclusive on the API; pass 0 for the unused field. """ - # Define EIP-712 domain - domain = { - "name": "Reya", - "version": "1", - "verifyingContract": self.config.default_orders_gateway_address, - } - - # Define the message types for EIP-712 (OrderCancel format for SPOT) types = { "OrderCancel": [ {"name": "verifyingChainId", "type": "uint64"}, @@ -255,7 +179,6 @@ def sign_cancel_order_spot( ], } - # Create the message to sign message = { "verifyingChainId": self._chain_id, "deadline": deadline, @@ -268,14 +191,8 @@ def sign_cancel_order_spot( }, } - # Sign the message using EIP-712 - signed_message = Account.sign_typed_data(self._private_key, domain, types, message) - - return ( - signed_message.signature.hex() - if signed_message.signature.hex().startswith("0x") - else f"0x{signed_message.signature.hex()}" - ) + signed_message = Account.sign_typed_data(self._private_key, self._domain, types, message) + return _to_hex_signature(signed_message.signature.hex()) def sign_mass_cancel( self, @@ -284,29 +201,10 @@ def sign_mass_cancel( nonce: int, deadline: int, ) -> str: - """ - Sign a mass cancel request using EIP-712 (for SPOT orders). + """Sign a MassCancel envelope (matching-engine layer). - This method generates an EIP-712 signature for cancelling all orders - for a specific account and market. - - Args: - account_id: The Reya account ID - market_id: The market ID - nonce: Unique nonce for this mass cancel (microsecond timestamp) - deadline: Signature expiration timestamp (milliseconds) - - Returns: - Hex-encoded signature - """ - # Define EIP-712 domain - domain = { - "name": "Reya", - "version": "1", - "verifyingContract": self.config.default_orders_gateway_address, - } - - # Define the message types for EIP-712 (MassCancel format for SPOT) + Works for both spot and perp markets. Pass `market_id=0` to cancel + across all markets (the API treats omitted `symbol` as wildcard).""" types = { "MassCancel": [ {"name": "verifyingChainId", "type": "uint64"}, @@ -320,7 +218,6 @@ def sign_mass_cancel( ], } - # Create the message to sign message = { "verifyingChainId": self._chain_id, "deadline": deadline, @@ -331,11 +228,10 @@ def sign_mass_cancel( }, } - # Sign the message using EIP-712 - signed_message = Account.sign_typed_data(self._private_key, domain, types, message) + signed_message = Account.sign_typed_data(self._private_key, self._domain, types, message) + return _to_hex_signature(signed_message.signature.hex()) + - return ( - signed_message.signature.hex() - if signed_message.signature.hex().startswith("0x") - else f"0x{signed_message.signature.hex()}" - ) +def _to_hex_signature(sig_hex: str) -> str: + """Normalize an eth_account signature hex to a 0x-prefixed string.""" + return sig_hex if sig_hex.startswith("0x") else f"0x{sig_hex}" diff --git a/sdk/reya_rest_api/client.py b/sdk/reya_rest_api/client.py index cdeb7f0f..5d1470c1 100644 --- a/sdk/reya_rest_api/client.py +++ b/sdk/reya_rest_api/client.py @@ -2,6 +2,8 @@ Reya Trading Client - Main entry point for the Reya Trading API. This module provides a client for interacting with the Reya Trading REST API. +The order entry surface is unified across spot and perp markets — all orders +flow through the same `Order` EIP-712 envelope and matching-engine pipeline. """ from typing import Optional @@ -24,6 +26,7 @@ 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.execution_bust_list import ExecutionBustList 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 @@ -31,20 +34,53 @@ from sdk.open_api.models.order_type import OrderType from sdk.open_api.models.perp_execution_list import PerpExecutionList from sdk.open_api.models.position import Position -from sdk.open_api.models.spot_execution_bust_list import SpotExecutionBustList from sdk.open_api.models.spot_execution_list import SpotExecutionList from sdk.open_api.models.time_in_force import TimeInForce from sdk.open_api.models.wallet_configuration import WalletConfiguration -from sdk.reya_rest_api.auth.signatures import SignatureGenerator +from sdk.reya_rest_api.auth.signatures import OrderTypeInt, SignatureGenerator, TimeInForceInt from sdk.reya_rest_api.config import TradingConfig, get_config -from sdk.reya_rest_api.constants.enums import OrdersGatewayOrderType from .models.orders import LimitOrderParameters, TriggerOrderParameters -CONDITIONAL_ORDER_DEADLINE = 10**18 -DEFAULT_DEADLINE_S = 10 # Default deadline for IOC orders and cancel operations -GTC_DEADLINE_S = 86400 # 24 hours for GTC spot orders -BUY_TRIGGER_ORDER_PRICE_LIMIT = 100000000000000000000 +# Two INDEPENDENT signed time fields: +# - `deadline` — EIP-712 signature-validity window, enforced off-chain at +# entry ONLY (on-chain verification skips the deadline). +# Bounds how long a signed payload may be submitted; +# irrelevant once the order rests. +# - `expiresAfter` — the on-chain ORDER LIFETIME, enforced at settlement and by +# the matching engine (`expiresAfter == 0` => never expires). +# THIS is what keeps a resting order alive. +# The two are independent. GTC and IOC always sign `expiresAfter = 0` (GTC rests +# until filled or cancelled; IOC never rests, so its lifetime is moot) with a +# short entry-only `deadline`. Only GTT orders carry a non-zero `expiresAfter`, +# which must be greater than the `deadline` (the order has to outlive its own +# entry window). The matching engine accepts `expiresAfter == 0` as "no expiry". +DEFAULT_DEADLINE_S = 60 # signature-validity window (entry only), all order types. +PERPETUAL_LIFETIME = 0 # `expiresAfter` sentinel: never expires (GTC rests; IOC moot). + +# 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 +# market-type-conditional wire fields like `reduceOnly` without an extra network +# round-trip. +_SPOT_MARKET_ID_OFFSET = 10_000_000_000 + + +_ORDER_TYPE_TO_INT: dict[OrderType, OrderTypeInt] = { + OrderType.LIMIT: OrderTypeInt.LIMIT, + OrderType.STOP_LOSS: OrderTypeInt.STOP_LOSS, + 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. +_TIME_IN_FORCE_TO_INT: dict[TimeInForce, TimeInForceInt] = { + TimeInForce.GTC: TimeInForceInt.GTC, + TimeInForce.IOC: TimeInForceInt.IOC, +} class ResourceManager: @@ -61,52 +97,35 @@ class ReyaTradingClient: """ Client for interacting with the Reya Trading API. - This class provides a high-level interface to the Reya Trading API, - with resources for managing orders and accounts. + Order entry, cancellation, and mass-cancel are unified across spot and + perp markets — the matching engine handles routing based on `symbol` and + `orderType`. """ - # Class-level nonce tracking per wallet address (shared across all instances) + # Class-level nonce tracking per wallet address (shared across instances) _wallet_nonces: dict[str, int] = {} _wallet_nonce_lock = threading.Lock() def __init__(self, config: Optional[TradingConfig] = None): - """ - Initialize the Reya Trading client. - - Args: - config: Optional trading configuration object. If provided, it will be used - directly. If not provided, config will be loaded from environment - variables using get_config(). - """ - # Initialize symbol to market_id mapping self._symbol_to_market_id: dict[str, int] = {} + # Perp tick size (price spacing) per symbol, from MarketDefinition. + # Used to pick a price-spacing-conforming sentinel limit price for + # TP/SL triggers when the caller doesn't pin one. Perp-only: triggers + # aren't supported on spot. + self._symbol_to_tick_size: dict[str, str] = {} self._initialized = False - # Setup logging self.logger = logging.getLogger("reya_trading.client") - - # Use provided config or load from environment self._config = config if config is not None else get_config() - - # Create signature generator self._signature_generator = SignatureGenerator(self._config) - # Initialize resource manager api_config = Configuration(host=self._config.api_url) self.logger.info(f"API URL: {api_config.host}") - self.logger.info(f"API base path: {api_config._base_path}") api_client = ApiClient(api_config) - # Set custom SDK headers for all requests api_client.set_default_header("X-SDK-Version", f"reya-python-sdk/{SDK_VERSION}") api_client.set_default_header("User-Agent", f"reya-python-sdk/{SDK_VERSION}") - # Verify ApiClient host configuration - if hasattr(api_client, "configuration"): - self.logger.info(f"ApiClient configuration host: {api_client.configuration.host}") - else: - self.logger.warning("ApiClient does not have configuration attribute") - self._resources = ResourceManager(api_client) self._api_client = api_client @@ -115,61 +134,38 @@ async def start(self) -> None: async def _load_market_definitions(self) -> None: """Load both perp and spot market definitions.""" - perp_count = 0 - spot_count = 0 - - # Try to load perp market definitions (may fail if risk matrix data is missing) market_definitions: list[MarketDefinition] = await self.reference.get_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) - self.logger.info(f"Loaded {perp_count} perp market definitions") - # Load spot market definitions from /spotMarketDefinitions endpoint spot_market_definitions = await self.reference.get_spot_market_definitions() for market in spot_market_definitions: self._symbol_to_market_id[market.symbol] = market.market_id spot_count = len(spot_market_definitions) - self.logger.info(f"Loaded {spot_count} spot market definitions from /spotMarketDefinitions") self._initialized = True total_markets = perp_count + spot_count - self.logger.info(f"Loaded {total_markets} total market definitions ({perp_count} perp, {spot_count} spot)") - - def _is_spot_market(self, symbol: str) -> bool: - """ - Determine if a symbol represents a spot market. - - Logic: If the symbol does NOT end with 'PERP', it's a spot market. - Examples: ETHRUSD (spot), BTCRUSD (spot), ETHRUSDPERP (perp) - """ - return not symbol.upper().endswith("PERP") + self.logger.info(f"Loaded {total_markets} market definitions ({perp_count} perp, {spot_count} spot)") def _get_next_nonce(self) -> int: - """ - Generate a monotonically increasing nonce for spot market operations. - - Uses microsecond timestamp as base, but ensures the nonce is always - greater than the last used nonce to prevent race conditions when - multiple orders are created in quick succession. + """Generate a strictly-increasing per-wallet nonce. - Nonces are tracked per-wallet at the class level, so multiple client - instances sharing the same wallet will use the same nonce counter. - - Returns: - A unique nonce guaranteed to be greater than any previously returned nonce. + Microsecond timestamp as base, advanced past any prior nonce in the + same wallet to prevent races when multiple orders are signed in + quick succession. Per-wallet at the class level so multiple client + instances sharing a wallet share the same counter. """ wallet_address = self._config.owner_wallet_address.lower() with ReyaTradingClient._wallet_nonce_lock: current_time_nonce = int(time.time() * 1_000_000) last_nonce = ReyaTradingClient._wallet_nonces.get(wallet_address, 0) - # Ensure nonce is always greater than the last used nonce new_nonce = max(current_time_nonce, last_nonce + 1) ReyaTradingClient._wallet_nonces[wallet_address] = new_nonce return new_nonce def get_market_id_from_symbol(self, symbol: str) -> int: - """Get market_id from symbol. Raises ValueError if symbol not found.""" if not self._initialized: raise ValueError("Client not initialized. Call start() first.") @@ -178,126 +174,159 @@ def get_market_id_from_symbol(self, symbol: str) -> int: available_symbols = list(self._symbol_to_market_id.keys()) raise ValueError(f"Unknown symbol '{symbol}'. Available symbols: {available_symbols}") - is_spot = self._is_spot_market(symbol) - self.logger.debug(f"Symbol '{symbol}' resolved to market_id {market_id} ({'spot' if is_spot else 'perp'})") - return market_id + def _tick_size_for(self, symbol: str) -> str: + """Return the perp market's tick size (price spacing) for ``symbol``. + + Used to pick a price-spacing-conforming sentinel limit price for TP/SL + triggers. Perp-only — triggers aren't supported on spot. + """ + if not self._initialized: + raise ValueError("Client not initialized. Call start() first.") + tick_size = self._symbol_to_tick_size.get(symbol) + if tick_size is None: + raise ValueError(f"No tick size for perp symbol '{symbol}'. Trigger orders are perp-only.") + return tick_size + @property def orders(self) -> OrderEntryApi: - """Get the orders resource.""" return self._resources.orders @property def wallet(self) -> WalletDataApi: - """Get the wallet resource.""" return self._resources.wallet @property def markets(self) -> MarketDataApi: - """Get the markets resource.""" return self._resources.markets @property def reference(self) -> ReferenceDataApi: - """Get the reference data resource.""" return self._resources.reference @property def config(self) -> TradingConfig: - """Get the current configuration.""" return self._config @property def signature_generator(self) -> SignatureGenerator: - """Get the signature generator for creating order signatures.""" return self._signature_generator def get_next_nonce(self) -> int: - """Get the next nonce for order signing. - - Returns: - A unique nonce guaranteed to be greater than any previously returned nonce. - """ return self._get_next_nonce() @property def signer_wallet_address(self) -> str: - """Get the signer wallet address (derived from private key).""" return self._signature_generator.signer_wallet_address @property def owner_wallet_address(self) -> str: """ - Get the owner wallet address for querying wallet data. - - Wallet that owns ACCOUNT_ID, the signer_wallet will either be the same as owner_wallet_address, or a wallet - that was given permissions to trade on behalf ot he owner_wallet_address + Wallet that owns ACCOUNT_ID. The signer wallet is either this wallet + or one with delegated trading permission. """ return self._config.owner_wallet_address def build_create_limit_order_payload(self, params: LimitOrderParameters) -> tuple[dict, int]: - """Build the wire-shape payload (camelCase, JSON-ready) for a createOrder - limit-order request, and return ``(payload, nonce)``. + """Build the camelCase wire payload for a createOrder LIMIT request and + return ``(payload, nonce)``. - Pure (no I/O). The same payload shape is consumed by both the REST - ``OrderEntryApi`` and the ws-exec WebSocket transport — the generated - OpenAPI ``CreateOrderRequest`` and AsyncAPI ``CreateOrderRequest`` - models share field names, so the dict round-trips through either. + Pure (no I/O). The payload round-trips through both the REST + ``CreateOrderRequest`` (OpenAPI) and the ws-exec ``CreateOrderRequest`` + (AsyncAPI) — they share field names — so the ws-exec transport reuses + this exact unified signing path instead of duplicating it. """ - if self._signature_generator is None: - raise ValueError("Signature generator is required for order signing") 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") - is_spot = self._is_spot_market(params.symbol) market_id = self.get_market_id_from_symbol(params.symbol) - - if params.expires_after is not None and params.time_in_force != TimeInForce.IOC and not is_spot: - raise ValueError("Parameter expires_after is only allowed for IOC orders on perp markets") - - if params.time_in_force == TimeInForce.GTC and params.reduce_only is True: - raise ValueError("Unexpected True value for parameter reduce_only for GTC orders") - - # Spot markets use a monotonically-increasing wall-clock-derived nonce - # (fits in uint64); perp markets use the OrdersGateway encoded nonce. - if is_spot: - nonce = self._get_next_nonce() - else: - nonce = self._signature_generator.create_orders_gateway_nonce( - self.config.account_id, market_id, int(time.time_ns() / 1000000) + is_spot_market = market_id >= _SPOT_MARKET_ID_OFFSET + 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)" ) - inputs = self._signature_generator.encode_inputs_limit_order( - is_buy=params.is_buy, - limit_px=Decimal(params.limit_px), - qty=Decimal(params.qty), - ) - - deadline = self._resolve_limit_order_deadline(params, is_spot) - - order_type_int = self._resolve_limit_order_type(params, is_spot) + nonce = self._get_next_nonce() - # Spot trades are matched against the orderbook; perps fill against the - # pool counterparty. - counterparty_ids = [] if is_spot else [self.config.pool_account_id] + # `deadline` (entry-time signature validity) and `expiresAfter` (on-chain + # order lifetime) are independent — see the constants block. Defaults: + # - deadline → now + 60s for every order type (short entry window) + # - expiresAfter → 0 / perpetual (GTC rests until filled or cancelled; + # IOC never rests, so its lifetime is moot) + # An explicit `params.deadline` / `params.expires_after` overrides each + # field independently. + deadline = params.deadline if params.deadline is not None else int(time.time()) + DEFAULT_DEADLINE_S + 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 + + # `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 + # perp IOC, and reject an explicit reduce-only elsewhere rather than + # silently dropping the caller's intent. The signed `OrderDetails.reduceOnly` + # mirrors the wire (False when not sent) so the on-chain digest matches. + if params.reduce_only and not is_perp_ioc: + raise ValueError("reduce_only is only supported on perp IOC orders") + reduce_only = bool(params.reduce_only) if is_perp_ioc else False + 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. + 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)" + ) + raise ValueError( + "post_only=True is not yet supported end-to-end " + "(pending the off-chain 14-field digest reconstruction)" + ) - signature = self._signature_generator.sign_raw_order( + signature = self._signature_generator.sign_order( account_id=self.config.account_id, market_id=market_id, exchange_id=self.config.dex_id, - counterparty_account_ids=counterparty_ids, - order_type=order_type_int, - inputs=inputs, - deadline=deadline, + order_type=int(OrderTypeInt.LIMIT), + is_buy=params.is_buy, + qty=Decimal(params.qty), + limit_price=Decimal(params.limit_px), + trigger_price=Decimal(0), + time_in_force=int(_TIME_IN_FORCE_TO_INT[params.time_in_force]), + client_order_id=client_order_id, + reduce_only=reduce_only, + expires_after=expires_after, nonce=nonce, + deadline=deadline, + post_only=post_only, ) - # `expiresAfter` is only sent on the wire for IOC perp + any spot; - # `reduceOnly` is only meaningful for perp IOC. - is_ioc_or_spot = params.time_in_force == TimeInForce.IOC or is_spot - is_perp_ioc = params.time_in_force == TimeInForce.IOC and not is_spot - payload = { "accountId": self.config.account_id, "symbol": params.symbol, @@ -307,123 +336,100 @@ def build_create_limit_order_payload(self, params: LimitOrderParameters) -> tupl "qty": params.qty, "orderType": OrderType.LIMIT.value, "timeInForce": params.time_in_force.value if params.time_in_force is not None else None, - "expiresAfter": deadline if is_ioc_or_spot else None, - "reduceOnly": params.reduce_only if is_perp_ioc 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, "signature": signature, "nonce": str(nonce), "signerWallet": self.signer_wallet_address, - "clientOrderId": params.client_order_id, + "deadline": deadline, } return payload, nonce - @staticmethod - def _resolve_limit_order_deadline(params: LimitOrderParameters, is_spot: bool) -> int: - """Resolve the EIP-712 deadline for a limit order based on TIF and market kind.""" - if params.time_in_force == TimeInForce.IOC: - return params.expires_after if params.expires_after is not None else int(time.time()) + DEFAULT_DEADLINE_S - # GTC branch - if not is_spot: - return CONDITIONAL_ORDER_DEADLINE - # GTC spot - if params.expires_after is None: - return int(time.time()) + GTC_DEADLINE_S - now = int(time.time()) - if params.expires_after <= now: - raise ValueError( - f"expires_after must be in the future for spot GTC orders " - f"(got {params.expires_after}, now is {now})" - ) - if params.expires_after > now + GTC_DEADLINE_S: - raise ValueError( - f"expires_after for spot GTC must be within {GTC_DEADLINE_S}s of now " - f"(got {params.expires_after - now}s in the future)" - ) - return params.expires_after - - @staticmethod - def _resolve_limit_order_type(params: LimitOrderParameters, is_spot: bool) -> int: - """Spot uses ``LIMIT_ORDER_SPOT`` regardless of TIF; perp branches on TIF + reduce-only.""" - if is_spot: - return int(OrdersGatewayOrderType.LIMIT_ORDER_SPOT) - if params.time_in_force == TimeInForce.GTC: - return int(OrdersGatewayOrderType.LIMIT_ORDER) - if params.reduce_only is True: - return int(OrdersGatewayOrderType.REDUCE_ONLY_MARKET_ORDER) - return int(OrdersGatewayOrderType.MARKET_ORDER) - async def create_limit_order(self, params: LimitOrderParameters) -> CreateOrderResponse: """ - Create a limit (IOC/GTC) order asynchronously. - - Args: - params: Limit order parameters + Create a LIMIT order (IOC or GTC) on either spot or perp markets. - Returns: - API response for the order creation + The matching engine routes by `symbol`. `reduce_only` is perp-only + and the API rejects it on spot. `expires_after` (order lifetime) is + signed and enforced on-chain at fill time; it is independent from + `deadline` (signature validity, enforced by the API at entry). """ payload, _nonce = self.build_create_limit_order_payload(params) - order_request = CreateOrderRequest(**payload) - - response = await self.orders.create_order(create_order_request=order_request) - - return response - - async def create_trigger_order(self, params: TriggerOrderParameters) -> CreateOrderResponse: - """ - Create a stop loss order asynchronously. - - Args: - params: Trigger order parameters - - Returns: - API response for the order creation - """ - - payload, _nonce = self.build_create_trigger_order_payload(params) - order_request = CreateOrderRequest(**payload) - return await self.orders.create_order(create_order_request=order_request) + return await self.orders.create_order(create_order_request=CreateOrderRequest(**payload)) def build_create_trigger_order_payload(self, params: TriggerOrderParameters) -> tuple[dict, int]: - """Build the wire-shape payload for a TP/SL trigger order. + """Build the camelCase wire payload for a STOP_LOSS / TAKE_PROFIT trigger + order and return ``(payload, nonce)``. - Pure; the same dict shape is consumed by REST and ws-exec. + Pure (no I/O). Shared verbatim by the REST sender below and the ws-exec + transport, so both produce identical signed envelopes. """ - if self._signature_generator is None: - raise ValueError("Signature generator is required for order signing") + if params.trigger_type not in (OrderType.STOP_LOSS, OrderType.TAKE_PROFIT): + raise ValueError(f"Unsupported trigger_type: {params.trigger_type}") if self.config.account_id is None: raise ValueError("Account ID is required for order signing") - if self._is_spot_market(params.symbol): - raise ValueError("Trigger orders are not supported for spot markets") + 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 = self._get_next_nonce() - limit_px = Decimal(BUY_TRIGGER_ORDER_PRICE_LIMIT) if params.is_buy else Decimal(0) - - order_type_int = int( - OrdersGatewayOrderType.TAKE_PROFIT - if params.trigger_type == OrderType.TP - else OrdersGatewayOrderType.STOP_LOSS - ) - - nonce = self._signature_generator.create_orders_gateway_nonce( - self.config.account_id, market_id, int(time.time_ns() / 1000000) - ) + # A TP/SL rests until its trigger fires, so its on-chain LIFETIME + # (`expiresAfter`) is 0 / perpetual — otherwise the stop is silently + # killed and the position left unprotected. `deadline` is the independent + # entry-time signature-validity window (now decoupled from `expiresAfter`; + # the deployed matching engine accepts `expiresAfter == 0`). An explicit + # `params.deadline` still wins. + deadline = params.deadline if params.deadline is not None else int(time.time()) + DEFAULT_DEADLINE_S + expires_after = PERPETUAL_LIFETIME + client_order_id = params.client_order_id if params.client_order_id is not None else 0 + + # reduce-only is server-rejected on non-IOC orders, and reduce-only / + # close-on-trigger TP/SL is still being designed. Reject an explicit + # reduce_only rather than sign+send a field the validator forbids; the + # wire omits `reduceOnly` entirely for triggers. + if params.reduce_only: + raise ValueError("reduce_only on TP/SL trigger orders is not supported yet") + + # If the caller didn't pin a worst-acceptable execution price, sign a + # sentinel that always lets the order through after the trigger fires: + # a huge price for buys (worst-case high), and the market's smallest + # tick for sells (worst-case low). The sell sentinel must be non-zero + # AND conform to the market's price spacing — an arbitrary tiny value + # like 0.000000001 is rejected by the matching engine as off-grid + # ("does not conform to price spacing"), so we use exactly one tick. + # The sentinel model itself (vs requiring an explicit limit_px, slippage + # bounds, etc.) is being revisited. + if params.limit_px is not None: + limit_price = Decimal(params.limit_px) + elif params.is_buy: + limit_price = Decimal("100000000000000000000") + else: + limit_price = Decimal(self._tick_size_for(params.symbol)) - inputs = self._signature_generator.encode_inputs_trigger_order( - is_buy=params.is_buy, - trigger_px=Decimal(str(params.trigger_px)), - limit_px=limit_px, - ) + order_type_int = _ORDER_TYPE_TO_INT[params.trigger_type] - signature = self._signature_generator.sign_raw_order( + signature = self._signature_generator.sign_order( account_id=self.config.account_id, market_id=market_id, exchange_id=self.config.dex_id, - counterparty_account_ids=[self.config.pool_account_id], - order_type=order_type_int, - inputs=inputs, - deadline=CONDITIONAL_ORDER_DEADLINE, + order_type=int(order_type_int), + is_buy=params.is_buy, + qty=Decimal(params.qty), + limit_price=limit_price, + trigger_price=Decimal(params.trigger_px), + time_in_force=int(TimeInForceInt.GTC), + client_order_id=client_order_id, + reduce_only=False, + expires_after=expires_after, nonce=nonce, + deadline=deadline, ) payload = { @@ -431,324 +437,223 @@ def build_create_trigger_order_payload(self, params: TriggerOrderParameters) -> "symbol": params.symbol, "exchangeId": self.config.dex_id, "isBuy": params.is_buy, + # Fixed-point, never scientific notation. A small tick (e.g. + # str(Decimal("0.0000001")) == "1E-7") would otherwise reach the + # wire in sci notation, which the server's ethers FixedNumber parser + # rejects with INVALID_ARGUMENT. format(..., "f") renders a plain + # decimal for any tick size or caller-supplied price. + "limitPx": format(limit_price, "f"), + "qty": params.qty, "triggerPx": str(params.trigger_px), - "limitPx": str(limit_px), "orderType": params.trigger_type.value, - "expiresAfter": None, + "reduceOnly": None, + "expiresAfter": expires_after, + "clientOrderId": params.client_order_id, "signature": signature, "nonce": str(nonce), "signerWallet": self.signer_wallet_address, + "deadline": deadline, } return payload, nonce + async def create_trigger_order(self, params: TriggerOrderParameters) -> CreateOrderResponse: + """ + Create a STOP_LOSS or TAKE_PROFIT trigger order on a perp market. + + When the trigger price is hit, the matching engine places a limit + order at `limit_px` for the signed `qty`. Spot triggers are not + supported by the API. + """ + payload, _nonce = self.build_create_trigger_order_payload(params) + return await self.orders.create_order(create_order_request=CreateOrderRequest(**payload)) + async def cancel_order( self, - order_id: Optional[str] = None, - symbol: Optional[str] = None, + symbol: str, account_id: Optional[int] = None, + order_id: Optional[str] = None, client_order_id: Optional[int] = None, ) -> CancelOrderResponse: """ - Cancel an existing order asynchronously. - - For spot markets, you must provide EITHER order_id OR client_order_id (not both). - For perp markets, order_id is required. + Cancel a single open order. At least one of `order_id` or + `client_order_id` must be provided. Works on both spot and perp + markets. - Args: - order_id: ID of the order to cancel (required for perp, optional for spot if client_order_id provided) - symbol: Trading symbol (required for spot market orders, e.g., ETHRUSD, BTCRUSD) - account_id: Account ID (required for spot market orders) - client_order_id: Client order ID (optional for spot, alternative to order_id) - - Returns: - API response for the order cancellation - - Raises: - ValueError: If symbol and account_id are not provided for spot orders - ValueError: If neither order_id nor client_order_id is provided for spot orders + Precedence note: the off-chain matching-engine controller accepts + both fields and prefers `order_id` as the canonical identifier + (falling back to `client_order_id` only when `order_id` is absent). + The OpenAPI docstring on ``CancelOrderRequest.orderId`` historically + says "not both", but that's a recommended client contract — the server + tolerates both and resolves deterministically. We therefore only + enforce "at least one" here, matching the on-the-wire behaviour rather + than the stricter docstring. """ payload = self.build_cancel_order_payload( - order_id=order_id, symbol=symbol, account_id=account_id, + order_id=order_id, client_order_id=client_order_id, ) - cancel_order_request = CancelOrderRequest(**payload) - return await self.orders.cancel_order(cancel_order_request) + return await self.orders.cancel_order(CancelOrderRequest(**payload)) def build_cancel_order_payload( self, - order_id: Optional[str] = None, symbol: Optional[str] = None, account_id: Optional[int] = None, + order_id: Optional[str] = None, client_order_id: Optional[int] = None, ) -> dict: - """Build the wire-shape payload for a cancelOrder request. Pure; reused by REST and ws-exec.""" + """Build the camelCase wire payload for a cancelOrder request. + + Pure (no I/O). Shared by the REST sender above and the ws-exec + transport. Same arg semantics as :meth:`cancel_order`. + + `symbol` is typed Optional only to match the ws-exec client's + forwarding signature; it is required in practice (the cancel + EIP-712 envelope signs the resolved marketId), so a missing symbol + raises rather than silently producing an unsigned-for-market cancel. + """ + if not symbol: + raise ValueError("symbol is required to cancel an order") + if order_id is None and client_order_id is None: + raise ValueError("Provide either order_id or client_order_id") + + 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 cancelling orders") + raise ValueError("Signature generator is required for order signing") - is_spot_order = bool(symbol and "RUSD" in symbol and "PERP" not in symbol) + market_id = self.get_market_id_from_symbol(symbol) + nonce = self._get_next_nonce() + deadline = int(time.time()) + DEFAULT_DEADLINE_S - if is_spot_order: - if symbol is None: - raise ValueError("symbol is required for spot market order cancellation") - if account_id is None: - raise ValueError(f"account_id is required for spot market order cancellation (symbol: {symbol})") - if order_id is None and client_order_id is None: - raise ValueError("For spot orders, must provide either order_id or client_order_id") - else: - if order_id is None: - raise ValueError("order_id is required for perp market order cancellation") - - nonce: Optional[int] - deadline: Optional[int] - - if is_spot_order: - assert symbol is not None - assert account_id is not None - market_id = self.get_market_id_from_symbol(symbol) - nonce = self._get_next_nonce() - deadline = int(time.time()) + DEFAULT_DEADLINE_S - - # The EIP-712 schema needs both ids; zero acts as the "absent" sentinel. - order_id_int = int(order_id) if order_id is not None else 0 - client_order_id_int = client_order_id if client_order_id is not None else 0 - - signature = self._signature_generator.sign_cancel_order_spot( - account_id=account_id, - market_id=market_id, - order_id=order_id_int, - client_order_id=client_order_id_int, - nonce=nonce, - deadline=deadline, - ) - else: - assert order_id is not None - signature = self._signature_generator.sign_cancel_order_perps(order_id) - nonce = None - deadline = None + order_id_int = int(order_id) if order_id is not None else 0 + client_order_id_int = client_order_id if client_order_id is not None else 0 + + signature = self._signature_generator.sign_cancel_order( + account_id=resolved_account_id, + market_id=market_id, + order_id=order_id_int, + client_order_id=client_order_id_int, + nonce=nonce, + deadline=deadline, + ) return { + "symbol": symbol, + "accountId": resolved_account_id, "orderId": order_id, "clientOrderId": client_order_id, "signature": signature, - "nonce": str(nonce) if nonce is not None else None, - "symbol": symbol, - "accountId": account_id, - "expiresAfter": deadline, + "nonce": str(nonce), + "deadline": deadline, } async def mass_cancel( self, - symbol: str, + symbol: Optional[str] = None, account_id: Optional[int] = None, ) -> MassCancelResponse: """ - Cancel all orders for a specific market asynchronously. - - This operation is only supported for SPOT markets. - - Args: - symbol: Trading symbol (e.g., ETHRUSD, BTCRUSD) - account_id: Account ID (optional, defaults to config account_id) - - Returns: - API response for the mass cancellation - - Raises: - ValueError: If symbol is not a spot market or account_id is missing + Cancel all open orders for an account, optionally filtered by + market. Works on both spot and perp markets. Pass `symbol=None` to + cancel across all markets the account has orders in. """ payload = self.build_mass_cancel_payload(symbol=symbol, account_id=account_id) - mass_cancel_request = MassCancelRequest(**payload) - return await self.orders.cancel_all(mass_cancel_request) + return await self.orders.cancel_all(MassCancelRequest(**payload)) def build_mass_cancel_payload( self, - symbol: Optional[str], + symbol: Optional[str] = None, account_id: Optional[int] = None, ) -> dict: - """Build the wire-shape payload for a mass-cancel request. + """Build the camelCase wire payload for a cancelAll (mass-cancel) request. - ``symbol=None`` is account-wide cancel; the EIP-712 typed data is then - signed with ``market_id=0`` (the server reconstructs the same hash). + Pure (no I/O). Shared by the REST sender above and the ws-exec + transport. Pass ``symbol=None`` to cancel across all markets. """ + 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 mass cancel") - - if symbol is not None and not self._is_spot_market(symbol): - raise ValueError( - f"Mass cancel is only supported for spot markets. Symbol '{symbol}' appears to be a perp market." - ) - - if account_id is None: - account_id = self.config.account_id - if account_id is None: - raise ValueError("account_id is required for mass cancel") + raise ValueError("Signature generator is required for order signing") market_id = self.get_market_id_from_symbol(symbol) if symbol is not None else 0 nonce = self._get_next_nonce() deadline = int(time.time()) + DEFAULT_DEADLINE_S signature = self._signature_generator.sign_mass_cancel( - account_id=account_id, + account_id=resolved_account_id, market_id=market_id, nonce=nonce, deadline=deadline, ) return { - "accountId": account_id, + "accountId": resolved_account_id, "symbol": symbol, "signature": signature, "nonce": str(nonce), - "expiresAfter": deadline, + "deadline": deadline, } async def get_positions(self, wallet_address: Optional[str] = None) -> list[Position]: - """ - Get positions for a wallet address asynchronously. - - Args: - wallet_address: Optional wallet address (defaults to owner_wallet_address) - - Returns: - Positions data - - Raises: - ValueError: If no wallet address is available or API returns an error - """ wallet = wallet_address or self.owner_wallet_address if not wallet: - raise ValueError("No wallet address available. Private key must be provided.") - + raise ValueError("No wallet address available.") return await self.wallet.get_wallet_positions(address=wallet) async def get_open_orders(self) -> list[Order]: - """ - Get open orders for the owner wallet asynchronously. - - Returns: - List of open orders - - Raises: - ValueError: If no wallet address is available or API returns an error - """ wallet = self.owner_wallet_address if not wallet: - raise ValueError("No wallet address available. Private key must be provided.") - + raise ValueError("No wallet address available.") return await self.wallet.get_wallet_open_orders(address=wallet) async def get_configuration(self) -> WalletConfiguration: - """ - Get account configuration for the owner wallet asynchronously. - - Returns: - Account configuration information - - Raises: - ValueError: If no wallet address is available or API returns an error - """ wallet = self.owner_wallet_address if not wallet: - raise ValueError("No wallet address available. Private key must be provided.") - + raise ValueError("No wallet address available.") return await self.wallet.get_wallet_configuration(address=wallet) async def get_perp_executions(self) -> PerpExecutionList: - """ - Get perp executions for the owner wallet asynchronously. - - Returns: - Dictionary containing trades data and metadata - - Raises: - ValueError: If no wallet address is available or API returns an error - """ wallet = self.owner_wallet_address if not wallet: - raise ValueError("No wallet address available. Private key must be provided.") - + raise ValueError("No wallet address available.") return await self.wallet.get_wallet_perp_executions(address=wallet) async def get_accounts(self) -> list[Account]: - """ - Get accounts for the owner wallet asynchronously. - - Returns: - Account information - - Raises: - ValueError: If no wallet address is available or API returns an error - """ wallet = self.owner_wallet_address if not wallet: - raise ValueError("No wallet address available. Private key must be provided.") - + raise ValueError("No wallet address available.") return await self.wallet.get_wallet_accounts(address=wallet) async def get_account_balances(self) -> list[AccountBalance]: - """ - Get account balances for the owner wallet asynchronously. - - Returns: - Account balances - - Raises: - ValueError: If no wallet address is available or API returns an error - """ wallet = self.owner_wallet_address if not wallet: - raise ValueError("No wallet address available. Private key must be provided.") - + raise ValueError("No wallet address available.") return await self.wallet.get_wallet_account_balances(address=wallet) async def get_spot_executions(self) -> SpotExecutionList: - """ - Get spot executions (i.e. auto exchanges) for the owner wallet asynchronously. - - Returns: - Spot executions - - Raises: - ValueError: If no wallet address is available or API returns an error - """ wallet = self.owner_wallet_address if not wallet: - raise ValueError("No wallet address available. Private key must be provided.") - + raise ValueError("No wallet address available.") return await self.wallet.get_wallet_spot_executions(address=wallet) - async def get_spot_execution_busts(self) -> SpotExecutionBustList: - """ - Get spot execution busts (failed spot fills) for the owner wallet asynchronously. - - Returns: - Spot execution busts - - Raises: - ValueError: If no wallet address is available or API returns an error - """ + async def get_execution_busts(self) -> ExecutionBustList: + """Get execution busts (failed fills) across spot and perp markets + for the owner wallet.""" wallet = self.owner_wallet_address if not wallet: - raise ValueError("No wallet address available. Private key must be provided.") - - return await self.wallet.get_wallet_spot_execution_busts(address=wallet) + raise ValueError("No wallet address available.") + return await self.wallet.get_wallet_execution_busts(address=wallet) async def close(self) -> None: - """ - Close the underlying HTTP client session. - - This should be called when the client is no longer needed to properly - cleanup HTTP connections and avoid resource leaks. - """ if hasattr(self._api_client, "rest_client") and self._api_client.rest_client: await self._api_client.rest_client.close() async def __aenter__(self): - """Async context manager entry.""" return self async def __aexit__(self, exc_type, exc_val, exc_tb): - """Async context manager exit - automatically closes the client session.""" await self.close() diff --git a/sdk/reya_rest_api/config.py b/sdk/reya_rest_api/config.py index a8e69da7..857f998f 100644 --- a/sdk/reya_rest_api/config.py +++ b/sdk/reya_rest_api/config.py @@ -10,7 +10,19 @@ from dotenv import load_dotenv MAINNET_CHAIN_ID = 1729 -REYA_DEX_ID = 2 + +# OrdersGateway proxy = the EIP-712 verifyingContract, per deployment. devnet1 +# (the perpOB testnet) and the cronos testnet share chain id 89346162 but use +# different proxies, so REYA_ORDERS_GATEWAY selects between them (set per +# environment in .env.example). Cronos is reachable only via that override. +MAINNET_ORDERS_GATEWAY = "0xfc8c96be87da63cecddbf54abfa7b13ee8044739" +DEVNET1_ORDERS_GATEWAY = "0x7Ec89E555c771D2B5939aBE5C4E4291852633D4D" +CRONOS_ORDERS_GATEWAY = "0x5a0ac2f89e0bdeafc5c549e354842210a3e87ca5" + +# Default exchange id resolved at import time. Set REYA_DEX_ID in the +# environment to override (e.g., devnet1 only registers exchange id 1). +# `TradingConfig.dex_id_override` still wins per-instance if set. +REYA_DEX_ID = int(os.environ.get("REYA_DEX_ID", "2")) @dataclass @@ -22,6 +34,8 @@ class TradingConfig: owner_wallet_address: str private_key: Optional[str] = None account_id: Optional[int] = None + orders_gateway_address: Optional[str] = None + dex_id_override: Optional[int] = None @property def is_mainnet(self) -> bool: @@ -30,21 +44,32 @@ def is_mainnet(self) -> bool: @property def dex_id(self) -> int: - """Get DEX ID""" + """Exchange id used as `OrderDetails.exchangeId` in signed orders. + + Resolves to the ``REYA_DEX_ID`` env var when set (via + ``from_env``/``from_env_spot``), otherwise the canonical default. + The override exists because non-mainnet deployments (devnet1, + future testnets) may not have registered the canonical id-2 + exchange yet — using id 1 (passive pool) lets order-entry tests + run end-to-end on those environments. Switch back to the + default once the target deployment registers id 2. + """ + if self.dex_id_override is not None: + return self.dex_id_override return REYA_DEX_ID @property def default_orders_gateway_address(self) -> str: - """Get default OrdersGateway proxy contract address based on chain ID""" - if self.is_mainnet: - return "0xfc8c96be87da63cecddbf54abfa7b13ee8044739" # Mainnet address - else: - return "0x5a0ac2f89e0bdeafc5c549e354842210a3e87ca5" # Testnet address + """OrdersGateway proxy contract address used as the EIP-712 verifyingContract. - @property - def pool_account_id(self) -> int: - """Get pool account ID based on chain ID""" - return 2 if self.is_mainnet else 4 + Set ``REYA_ORDERS_GATEWAY`` per environment (see ``.env.example``); it + wins when present and is required to target cronos, which shares + devnet1's chain id. When unset, fall back to the mainnet or devnet1 + default by chain id. + """ + if self.orders_gateway_address: + return self.orders_gateway_address + return MAINNET_ORDERS_GATEWAY if self.is_mainnet else DEVNET1_ORDERS_GATEWAY @classmethod def from_env(cls) -> "TradingConfig": @@ -53,11 +78,11 @@ def from_env(cls) -> "TradingConfig": chain_id = int(os.environ.get("CHAIN_ID", MAINNET_CHAIN_ID)) - # Get API URL based on environment (mainnet or testnet) + # Get API URL based on environment (mainnet or devnet1, the perpOB testnet) if chain_id == MAINNET_CHAIN_ID: default_api_url = "https://api.reya.xyz/v2" else: - default_api_url = "https://api-cronos.reya.xyz/v2" + default_api_url = "https://api-devnet.reya-cronos.network/v2" # Require PERP_WALLET_ADDRESS_1 owner_wallet_address = os.environ.get("PERP_WALLET_ADDRESS_1") @@ -67,12 +92,15 @@ def from_env(cls) -> "TradingConfig": "This should be the wallet address whose data you want to query." ) + dex_id_env = os.environ.get("REYA_DEX_ID") return cls( api_url=os.environ.get("REYA_API_URL", default_api_url), chain_id=chain_id, owner_wallet_address=owner_wallet_address, private_key=os.environ.get("PERP_PRIVATE_KEY_1"), account_id=(int(os.environ["PERP_ACCOUNT_ID_1"]) if "PERP_ACCOUNT_ID_1" in os.environ else None), + orders_gateway_address=os.environ.get("REYA_ORDERS_GATEWAY"), + dex_id_override=int(dex_id_env) if dex_id_env else None, ) @classmethod @@ -95,11 +123,11 @@ def from_env_spot(cls, account_number: int = 1) -> "TradingConfig": chain_id = int(os.environ.get("CHAIN_ID", MAINNET_CHAIN_ID)) - # Get API URL based on environment (mainnet or testnet) + # Get API URL based on environment (mainnet or devnet1, the perpOB testnet) if chain_id == MAINNET_CHAIN_ID: default_api_url = "https://api.reya.xyz/v2" else: - default_api_url = "https://api-cronos.reya.xyz/v2" + default_api_url = "https://api-devnet.reya-cronos.network/v2" # Get SPOT account credentials owner_wallet_address = os.environ.get(f"SPOT_WALLET_ADDRESS_{account_number}") @@ -113,12 +141,15 @@ def from_env_spot(cls, account_number: int = 1) -> "TradingConfig": account_id_str = os.environ.get(f"SPOT_ACCOUNT_ID_{account_number}") account_id = int(account_id_str) if account_id_str else None + dex_id_env = os.environ.get("REYA_DEX_ID") return cls( api_url=os.environ.get("REYA_API_URL", default_api_url), chain_id=chain_id, owner_wallet_address=owner_wallet_address, private_key=private_key, account_id=account_id, + orders_gateway_address=os.environ.get("REYA_ORDERS_GATEWAY"), + dex_id_override=int(dex_id_env) if dex_id_env else None, ) diff --git a/sdk/reya_rest_api/constants/__init__.py b/sdk/reya_rest_api/constants/__init__.py deleted file mode 100644 index e39fbf4c..00000000 --- a/sdk/reya_rest_api/constants/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Constants module for Reya Trading SDK -""" diff --git a/sdk/reya_rest_api/constants/enums.py b/sdk/reya_rest_api/constants/enums.py deleted file mode 100644 index 68102ec9..00000000 --- a/sdk/reya_rest_api/constants/enums.py +++ /dev/null @@ -1,17 +0,0 @@ -""" -Enumeration classes for Reya Trading API. -""" - -from enum import IntEnum - - -class OrdersGatewayOrderType(IntEnum): - """Enum representing orders gateway order types""" - - STOP_LOSS = 0 - TAKE_PROFIT = 1 - LIMIT_ORDER = 2 - MARKET_ORDER = 3 - REDUCE_ONLY_MARKET_ORDER = 4 - FULL_CLOSE_ORDER = 5 - LIMIT_ORDER_SPOT = 6 diff --git a/sdk/reya_rest_api/models/orders.py b/sdk/reya_rest_api/models/orders.py index 91988417..fbb5a2fb 100644 --- a/sdk/reya_rest_api/models/orders.py +++ b/sdk/reya_rest_api/models/orders.py @@ -1,50 +1,51 @@ -from typing import Any, Optional +from typing import Optional from dataclasses import dataclass -from sdk.open_api.models import time_in_force from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.time_in_force import TimeInForce @dataclass(frozen=True) class LimitOrderParameters: - """Limit order parameters.""" + """Parameters for a LIMIT order on either spot or perp markets.""" symbol: str is_buy: bool limit_px: str qty: str - time_in_force: time_in_force.TimeInForce + time_in_force: TimeInForce reduce_only: Optional[bool] = None + # Maker-only intent: the order must rest, never cross as a taker. Valid on + # GTC; rejected on IOC (immediate-or-cancel is taker-only). Maps to on-chain + # `OrderDetails.postOnly`. None == not requested (treated as False). + post_only: Optional[bool] = None expires_after: Optional[int] = None client_order_id: Optional[int] = None - - def to_dict(self) -> dict[str, Any]: - return { - "symbol": self.symbol, - "is_buy": self.is_buy, - "limit_px": self.limit_px, - "qty": self.qty, - "reduce_only": self.reduce_only, - "expires_after": self.expires_after, - "time_in_force": self.time_in_force, - "client_order_id": self.client_order_id, - } + deadline: Optional[int] = None @dataclass(frozen=True) class TriggerOrderParameters: - """Trigger order parameters.""" + """Parameters for a STOP_LOSS or TAKE_PROFIT trigger order on a perp market. + + `qty` is the signed quantity to execute when the trigger fires — it must be + set explicitly. There is no safe default: signing a smaller-than-expected qty + silently produces a partial close, which can leave the user with the wrong + risk after a stop hits. + + `limit_px` is the worst-acceptable execution price after the trigger fires; + if omitted the client signs a sentinel — a very high value for buys, a very + low non-zero value for sells — so the order executes at any price available + after the trigger. + """ symbol: str is_buy: bool + qty: str trigger_px: str trigger_type: OrderType - - def to_dict(self) -> dict[str, Any]: - return { - "symbol": self.symbol, - "is_buy": self.is_buy, - "trigger_px": self.trigger_px, - "trigger_type": self.trigger_type, - } + limit_px: Optional[str] = None + reduce_only: Optional[bool] = None + client_order_id: Optional[int] = None + deadline: Optional[int] = None diff --git a/sdk/reya_rpc/utils/execute_core_commands.py b/sdk/reya_rpc/utils/execute_core_commands.py index d6fe1eac..baed1bf0 100644 --- a/sdk/reya_rpc/utils/execute_core_commands.py +++ b/sdk/reya_rpc/utils/execute_core_commands.py @@ -1,4 +1,4 @@ -from typing import Any +from typing import Any, cast from web3.types import TxReceipt @@ -23,7 +23,7 @@ def execute_core_commands(config: dict[str, Any], account_id: int, commands: lis # Send the raw transaction tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) - # Wait for the transaction receipt - tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + # Wait for the transaction receipt (web3 returns Any for the dynamic eth namespace). + tx_receipt: TxReceipt = cast(TxReceipt, w3.eth.wait_for_transaction_receipt(tx_hash)) - return tx_receipt # type: ignore[no-any-return] + return tx_receipt diff --git a/sdk/reya_websocket/resources/market.py b/sdk/reya_websocket/resources/market.py index 218db705..44134972 100644 --- a/sdk/reya_websocket/resources/market.py +++ b/sdk/reya_websocket/resources/market.py @@ -22,7 +22,7 @@ def __init__(self, socket: "ReyaSocket"): self._market_summary = MarketSummaryResource(socket) self._market_perp_executions = MarketPerpExecutionsResource(socket) self._market_spot_executions = MarketSpotExecutionsResource(socket) - self._market_spot_execution_busts = MarketSpotExecutionBustsResource(socket) + self._market_execution_busts = MarketExecutionBustsResource(socket) self._market_depth = MarketDepthResource(socket) @property @@ -63,16 +63,19 @@ def spot_executions(self, symbol: str) -> "MarketSpotExecutionsSubscription": """ return self._market_spot_executions.for_symbol(symbol) - def spot_execution_busts(self, symbol: str) -> "MarketSpotExecutionBustsSubscription": - """Get spot execution busts for a specific symbol. + def execution_busts(self, symbol: str) -> "MarketExecutionBustsSubscription": + """Get execution busts (failed fills) for a specific symbol. + + Unified across spot and perp markets. Pass a spot symbol (e.g. + ``ETHRUSD``) or a perp symbol (e.g. ``ETHRUSDPERP``). Args: - symbol: The trading symbol (e.g., "WETHRUSD", "BTCRUSD"). + symbol: The trading symbol. Returns: - A subscription object for the specified market spot execution busts. + A subscription object for the specified market execution busts. """ - return self._market_spot_execution_busts.for_symbol(symbol) + return self._market_execution_busts.for_symbol(symbol) def depth(self, symbol: str) -> "MarketDepthSubscription": """Get L2 market depth (orderbook) for a specific symbol. @@ -266,45 +269,45 @@ def unsubscribe(self) -> None: self.socket.send_unsubscribe(channel=self.path) -class MarketSpotExecutionBustsResource(SubscribableParameterizedResource): - """Resource for accessing market spot execution busts.""" +class MarketExecutionBustsResource(SubscribableParameterizedResource): + """Resource for accessing market execution busts (unified spot + perp).""" def __init__(self, socket: "ReyaSocket"): - """Initialize the market spot execution busts resource. + """Initialize the market execution busts resource. Args: socket: The WebSocket connection to use for this resource. """ - super().__init__(socket, "/v2/market/{symbol}/spotExecutionBusts") + super().__init__(socket, "/v2/market/{symbol}/executionBusts") - def for_symbol(self, symbol: str) -> "MarketSpotExecutionBustsSubscription": - """Create a subscription for a specific market's spot execution busts. + def for_symbol(self, symbol: str) -> "MarketExecutionBustsSubscription": + """Create a subscription for a specific market's execution busts. Args: - symbol: The trading symbol (e.g., "WETHRUSD", "BTCRUSD"). + symbol: The trading symbol (spot or perp, e.g. "WETHRUSD", "ETHRUSDPERP"). Returns: - A subscription object for the specified market spot execution busts. + A subscription object for the specified market's execution busts. """ - return MarketSpotExecutionBustsSubscription(self.socket, symbol) + return MarketExecutionBustsSubscription(self.socket, symbol) -class MarketSpotExecutionBustsSubscription: - """Manages a subscription to market spot execution busts for a specific symbol.""" +class MarketExecutionBustsSubscription: + """Manages a subscription to market execution busts for a specific symbol.""" def __init__(self, socket: "ReyaSocket", symbol: str): - """Initialize a market spot execution busts subscription. + """Initialize a market execution busts subscription. Args: socket: The WebSocket connection to use for this subscription. - symbol: The trading symbol (e.g., "WETHRUSD", "BTCRUSD"). + symbol: The trading symbol (spot or perp). """ self.socket = socket self.symbol = symbol - self.path = f"/v2/market/{symbol}/spotExecutionBusts" + self.path = f"/v2/market/{symbol}/executionBusts" def subscribe(self, batched: bool = False) -> None: - """Subscribe to market spot execution busts. + """Subscribe to market execution busts. Args: batched: Whether to receive updates in batches. @@ -312,7 +315,7 @@ def subscribe(self, batched: bool = False) -> None: self.socket.send_subscribe(channel=self.path, batched=batched) def unsubscribe(self) -> None: - """Unsubscribe from market spot execution busts.""" + """Unsubscribe from market execution busts.""" self.socket.send_unsubscribe(channel=self.path) diff --git a/sdk/reya_websocket/resources/wallet.py b/sdk/reya_websocket/resources/wallet.py index b16440f2..a00ef168 100644 --- a/sdk/reya_websocket/resources/wallet.py +++ b/sdk/reya_websocket/resources/wallet.py @@ -21,7 +21,7 @@ def __init__(self, socket: "ReyaSocket"): self._positions = WalletPositionsResource(socket) self._perp_executions = WalletPerpExecutionsResource(socket) self._spot_executions = WalletSpotExecutionsResource(socket) - self._spot_execution_busts = WalletSpotExecutionBustsResource(socket) + self._execution_busts = WalletExecutionBustsResource(socket) self._balances = WalletBalancesResource(socket) self._order_changes = WalletOrderChangesResource(socket) @@ -69,16 +69,18 @@ def balances(self, address: str) -> "WalletBalancesSubscription": """ return self._balances.for_wallet(address) - def spot_execution_busts(self, address: str) -> "WalletSpotExecutionBustsSubscription": - """Get spot execution busts for a specific wallet address. + def execution_busts(self, address: str) -> "WalletExecutionBustsSubscription": + """Get execution busts (failed fills) for a specific wallet address. + + Unified across spot and perp markets. Args: address: The wallet address. Returns: - A subscription object for the wallet spot execution busts. + A subscription object for the wallet execution busts. """ - return self._spot_execution_busts.for_wallet(address) + return self._execution_busts.for_wallet(address) def order_changes(self, address: str) -> "WalletOrderChangesSubscription": """Get order changes for a specific wallet address. @@ -292,34 +294,34 @@ def unsubscribe(self) -> None: self.socket.send_unsubscribe(channel=self.path) -class WalletSpotExecutionBustsResource(SubscribableParameterizedResource): - """Resource for accessing wallet spot execution busts.""" +class WalletExecutionBustsResource(SubscribableParameterizedResource): + """Resource for accessing wallet execution busts (unified spot + perp).""" def __init__(self, socket: "ReyaSocket"): - """Initialize the wallet spot execution busts resource. + """Initialize the wallet execution busts resource. Args: socket: The WebSocket connection to use for this resource. """ - super().__init__(socket, "/v2/wallet/{address}/spotExecutionBusts") + super().__init__(socket, "/v2/wallet/{address}/executionBusts") - def for_wallet(self, address: str) -> "WalletSpotExecutionBustsSubscription": - """Create a subscription for a specific wallet's spot execution busts. + def for_wallet(self, address: str) -> "WalletExecutionBustsSubscription": + """Create a subscription for a specific wallet's execution busts. Args: address: The wallet address. Returns: - A subscription object for the wallet spot execution busts. + A subscription object for the wallet's execution busts. """ - return WalletSpotExecutionBustsSubscription(self.socket, address) + return WalletExecutionBustsSubscription(self.socket, address) -class WalletSpotExecutionBustsSubscription: - """Manages a subscription to spot execution busts for a specific wallet.""" +class WalletExecutionBustsSubscription: + """Manages a subscription to execution busts for a specific wallet.""" def __init__(self, socket: "ReyaSocket", address: str): - """Initialize a wallet spot execution busts subscription. + """Initialize a wallet execution busts subscription. Args: socket: The WebSocket connection to use for this subscription. @@ -327,10 +329,10 @@ def __init__(self, socket: "ReyaSocket", address: str): """ self.socket = socket self.address = address - self.path = f"/v2/wallet/{address}/spotExecutionBusts" + self.path = f"/v2/wallet/{address}/executionBusts" def subscribe(self, batched: bool = False) -> None: - """Subscribe to wallet spot execution busts. + """Subscribe to wallet execution busts. Args: batched: Whether to receive updates in batches. @@ -338,7 +340,7 @@ def subscribe(self, batched: bool = False) -> None: self.socket.send_subscribe(channel=self.path, batched=batched) def unsubscribe(self) -> None: - """Unsubscribe from wallet spot execution busts.""" + """Unsubscribe from wallet execution busts.""" self.socket.send_unsubscribe(channel=self.path) diff --git a/sdk/reya_websocket/socket.py b/sdk/reya_websocket/socket.py index 036fa9e8..a802465d 100644 --- a/sdk/reya_websocket/socket.py +++ b/sdk/reya_websocket/socket.py @@ -19,8 +19,8 @@ from sdk.async_api.account_balance_update_payload import AccountBalanceUpdatePayload from sdk.async_api.error_message_payload import ErrorMessagePayload from sdk.async_api.market_depth_update_payload import MarketDepthUpdatePayload +from sdk.async_api.market_execution_bust_update_payload import MarketExecutionBustUpdatePayload from sdk.async_api.market_perp_execution_update_payload import MarketPerpExecutionUpdatePayload -from sdk.async_api.market_spot_execution_bust_update_payload import MarketSpotExecutionBustUpdatePayload from sdk.async_api.market_spot_execution_update_payload import MarketSpotExecutionUpdatePayload from sdk.async_api.market_summary_update_payload import MarketSummaryUpdatePayload from sdk.async_api.markets_summary_update_payload import MarketsSummaryUpdatePayload @@ -30,10 +30,12 @@ from sdk.async_api.position_update_payload import PositionUpdatePayload from sdk.async_api.price_update_payload import PriceUpdatePayload from sdk.async_api.prices_update_payload import PricesUpdatePayload +from sdk.async_api.spot_market_summary_update_payload import SpotMarketSummaryUpdatePayload +from sdk.async_api.spot_markets_summary_update_payload import SpotMarketsSummaryUpdatePayload from sdk.async_api.subscribed_message_payload import SubscribedMessagePayload from sdk.async_api.unsubscribed_message_payload import UnsubscribedMessagePayload +from sdk.async_api.wallet_execution_bust_update_payload import WalletExecutionBustUpdatePayload from sdk.async_api.wallet_perp_execution_update_payload import WalletPerpExecutionUpdatePayload -from sdk.async_api.wallet_spot_execution_bust_update_payload import WalletSpotExecutionBustUpdatePayload from sdk.async_api.wallet_spot_execution_update_payload import WalletSpotExecutionUpdatePayload from sdk.reya_websocket.config import WebSocketConfig, get_config from sdk.reya_websocket.resources.market import MarketResource @@ -56,16 +58,18 @@ # Market channels MarketsSummaryUpdatePayload, # /v2/markets/summary MarketSummaryUpdatePayload, # /v2/market/{symbol}/summary + SpotMarketsSummaryUpdatePayload, # /v2/spotMarkets/summary + SpotMarketSummaryUpdatePayload, # /v2/spotMarket/{symbol}/summary MarketPerpExecutionUpdatePayload, # /v2/market/{symbol}/perpExecutions MarketSpotExecutionUpdatePayload, # /v2/market/{symbol}/spotExecutions - MarketSpotExecutionBustUpdatePayload, # /v2/market/{symbol}/spotExecutionBusts + MarketExecutionBustUpdatePayload, # /v2/market/{symbol}/executionBusts MarketDepthUpdatePayload, # /v2/market/{symbol}/depth # Wallet channels PositionUpdatePayload, # /v2/wallet/{address}/positions OrderChangeUpdatePayload, # /v2/wallet/{address}/orderChanges WalletPerpExecutionUpdatePayload, # /v2/wallet/{address}/perpExecutions WalletSpotExecutionUpdatePayload, # /v2/wallet/{address}/spotExecutions - WalletSpotExecutionBustUpdatePayload, # /v2/wallet/{address}/spotExecutionBusts + WalletExecutionBustUpdatePayload, # /v2/wallet/{address}/executionBusts AccountBalanceUpdatePayload, # /v2/wallet/{address}/accountBalances # Price channels PricesUpdatePayload, # /v2/prices @@ -89,6 +93,7 @@ class ReyaSocket(WebSocketApp): "pong": PongMessagePayload, # All markets summary (exact match) "/v2/markets/summary": MarketsSummaryUpdatePayload, + "/v2/spotMarkets/summary": SpotMarketsSummaryUpdatePayload, # All prices (exact match) "/v2/prices": PricesUpdatePayload, } @@ -195,10 +200,12 @@ def _get_payload_type(self, channel: str) -> Optional[type[BaseModel]]: return MarketPerpExecutionUpdatePayload elif channel.endswith("/spotExecutions"): return MarketSpotExecutionUpdatePayload - elif channel.endswith("/spotExecutionBusts"): - return MarketSpotExecutionBustUpdatePayload + elif channel.endswith("/executionBusts"): + return MarketExecutionBustUpdatePayload elif channel.endswith("/depth"): return MarketDepthUpdatePayload + elif "/v2/spotMarket/" in channel and channel.endswith("/summary"): + return SpotMarketSummaryUpdatePayload elif "/v2/wallet/" in channel: if channel.endswith("/positions"): return PositionUpdatePayload @@ -208,8 +215,8 @@ def _get_payload_type(self, channel: str) -> Optional[type[BaseModel]]: return WalletPerpExecutionUpdatePayload elif channel.endswith("/spotExecutions"): return WalletSpotExecutionUpdatePayload - elif channel.endswith("/spotExecutionBusts"): - return WalletSpotExecutionBustUpdatePayload + elif channel.endswith("/executionBusts"): + return WalletExecutionBustUpdatePayload elif channel.endswith("/accountBalances"): return AccountBalanceUpdatePayload elif "/v2/prices/" in channel and channel != "/v2/prices": @@ -236,23 +243,23 @@ def _parse_message(self, message: dict) -> WebSocketMessage: try: if message_type == "ping": - return PingMessagePayload.model_validate(message) + return cast(WebSocketMessage, PingMessagePayload.model_validate(message)) elif message_type == "pong": - return PongMessagePayload.model_validate(message) + return cast(WebSocketMessage, PongMessagePayload.model_validate(message)) elif message_type == "subscribed": # Handle case where server returns contents as empty list instead of dict # Convert list to None to match the expected model type if "contents" in message and isinstance(message["contents"], list): message = {**message, "contents": None} - return SubscribedMessagePayload.model_validate(message) + return cast(WebSocketMessage, SubscribedMessagePayload.model_validate(message)) elif message_type == "unsubscribed": - return UnsubscribedMessagePayload.model_validate(message) + return cast(WebSocketMessage, UnsubscribedMessagePayload.model_validate(message)) elif message_type == "error": - return ErrorMessagePayload.model_validate(message) + return cast(WebSocketMessage, ErrorMessagePayload.model_validate(message)) elif message_type == "channel_data": channel = message.get("channel", "") diff --git a/specs b/specs index 8575d7cd..62055120 160000 --- a/specs +++ b/specs @@ -1 +1 @@ -Subproject commit 8575d7cd8b421ebe352a062ee42bf94fb9e31c87 +Subproject commit 620551206440933f2d0de3be1c807b7d18313f60 diff --git a/tests/conftest.py b/tests/conftest.py index b4fe164d..a6aab811 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,20 +5,35 @@ across all tests in a session, enabling session-scoped async fixtures. """ -import asyncio -import os -from decimal import Decimal - -import pytest -import pytest_asyncio +# pylint: disable=wrong-import-position,wrong-import-order +# Load .env BEFORE importing the SDK so module-level constants in +# `sdk.reya_rest_api.config` (e.g. `REYA_DEX_ID`) pick up devnet/staging +# overrides. Imports happen at conftest load time, so calling load_dotenv +# inside fixtures would be too late. The pylint disable above suppresses +# the `wrong-import-position` / `wrong-import-order` warnings this +# intentionally-out-of-order arrangement otherwise produces (flake8 is +# already silenced via `# noqa: E402`). from dotenv import load_dotenv -from sdk.open_api.exceptions import ApiException -from sdk.open_api.models import TimeInForce -from sdk.reya_rest_api.models.orders import LimitOrderParameters -from tests.helpers import ReyaTester -from tests.helpers.reya_tester import logger -from tests.test_spot.spot_config import SpotMarketConfig, SpotTestConfig, fetch_spot_market_configs +load_dotenv() + +import asyncio # noqa: E402 +import os # noqa: E402 +from decimal import Decimal # noqa: E402 + +import pytest # noqa: E402 +import pytest_asyncio # 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.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 + SpotMarketConfig, + SpotTestConfig, + fetch_spot_market_configs, +) # Time delay between tests TEST_DELAY_SECONDS = 0.1 @@ -264,6 +279,391 @@ async def taker_tester(taker_tester_session): # pylint: disable=redefined-outer await taker_tester_session.orders.close_all(fail_if_none=False) +# ============================================================================ +# Perp orderbook maker/taker fixtures +# ============================================================================ +# Under perpOB every fill needs a maker and a taker. PERP_ACCOUNT_ID_1 acts as +# the maker (resting GTC orders); PERP_ACCOUNT_ID_2 acts as the taker (IOC fills). +# Tests that don't need a counterparty can keep using the single-account +# ``reya_tester`` fixture defined above. + + +@pytest_asyncio.fixture(loop_scope="session", scope="session") +async def perp_maker_tester_session(): + """Session-scoped perp maker — uses PERP_ACCOUNT_ID_1 (default ReyaTester credentials).""" + load_dotenv() + + tester = ReyaTester(perp_account_number=1) + if not tester.owner_wallet_address or not tester.account_id: + pytest.skip( + "Missing perp account 1 configuration " + "(PERP_ACCOUNT_ID_1, PERP_PRIVATE_KEY_1, PERP_WALLET_ADDRESS_1) for perp tests" + ) + + logger.info(f"🔧 SESSION: Perp maker initialized: account_id={tester.account_id}") + await tester.setup() + yield tester + + try: + if tester.websocket: + tester.websocket.close() + await tester.positions.close_all(fail_if_none=False) + await tester.orders.close_all(fail_if_none=False) + await tester.client.close() + logger.info("✅ Perp maker session cleanup completed") + except (OSError, RuntimeError, asyncio.CancelledError) as e: + logger.warning(f"Error during perp maker cleanup: {e}") + + +@pytest_asyncio.fixture(loop_scope="session", scope="session") +async def perp_taker_tester_session(): + """Session-scoped perp taker — uses PERP_ACCOUNT_ID_2.""" + load_dotenv() + + tester = ReyaTester(perp_account_number=2) + if not tester.owner_wallet_address or not tester.account_id: + pytest.skip( + "Missing perp account 2 configuration " + "(PERP_ACCOUNT_ID_2, PERP_PRIVATE_KEY_2, PERP_WALLET_ADDRESS_2) for perp orderbook tests" + ) + + logger.info(f"🔧 SESSION: Perp taker initialized: account_id={tester.account_id}") + await tester.setup() + yield tester + + try: + if tester.websocket: + tester.websocket.close() + await tester.positions.close_all(fail_if_none=False) + await tester.orders.close_all(fail_if_none=False) + await tester.client.close() + logger.info("✅ Perp taker session cleanup completed") + except (OSError, RuntimeError, asyncio.CancelledError) as e: + logger.warning(f"Error during perp taker cleanup: {e}") + + +@pytest_asyncio.fixture(loop_scope="session", scope="function") +async def perp_flatten_between_tests( # 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. + + 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`). + + 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). + """ + 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", + ) + + yield + + 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="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 +): + """Function-scoped perp maker — clears orders/positions/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. + """ + 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 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 +): + """Function-scoped perp taker — clears orders/positions/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. + """ + 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 perp_taker_tester_session.orders.close_all(fail_if_none=False) + await perp_taker_tester_session.positions.close_all(fail_if_none=False) + + +# ============================================================================ +# Perp Position Guard (session-level orchestrated flatten) +# ============================================================================ +# Mirrors the `_execute_spot_transfer` + `spot_balance_guard` pattern (lines +# 445+ below) but for perp positions. Under perpOB there's no AMM counterparty, +# so a single-account reduce-only IOC has nothing to match against. Tests need +# orchestrated maker/taker close: one tester rests a GTC, the other crosses it +# 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, + symbol: str, + qty: str, + price: str, + side_a_is_buy: bool, +) -> bool: + """Cross a GTC on one side with a matching IOC on the other to move + both testers' positions by ±qty in opposite directions. + + Mirrors `_execute_spot_transfer`: + - `side_a` (with `side_a_is_buy=True/False`) places a GTC. + - `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. + """ + # 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. + gtc_params = LimitOrderParameters( + symbol=symbol, + is_buy=side_a_is_buy, + limit_px=price, + qty=qty, + time_in_force=TimeInForce.GTC, + ) + gtc_response = await side_a.client.create_limit_order(gtc_params) + gtc_order_id = gtc_response.order_id + if not gtc_order_id: + logger.error("perp_flatten: GTC creation returned no order_id") + return False + + await asyncio.sleep(0.3) + + ioc_params = LimitOrderParameters( + symbol=symbol, + is_buy=not side_a_is_buy, + limit_px=price, + qty=qty, + time_in_force=TimeInForce.IOC, + reduce_only=True, + ) + await side_b.client.create_limit_order(ioc_params) + + await asyncio.sleep(1.0) + + # Cancel any unmatched residual on the GTC side (shouldn't happen if the + # IOC fully crossed, but defensive). + try: + open_orders = await side_a.client.get_open_orders() + for order in open_orders: + if hasattr(order, "order_id") and order.order_id == gtc_order_id: + await side_a.client.cancel_order( + order_id=gtc_order_id, + symbol=symbol, + account_id=side_a.account_id, + ) + break + except (OSError, RuntimeError) as e: # nosec B110 + logger.debug(f"perp_flatten: GTC cancel failed (may have been fully filled): {e}") + + return True + + +PERP_GUARD_SYMBOL = "ETHRUSDPERP" + + +async def _flatten_to_zero( + maker: ReyaTester, + taker: ReyaTester, + symbol: str, + min_qty: Decimal, + qty_step: Decimal, + label: str, +) -> None: + """Drive both accounts' positions on `symbol` toward zero via an + orchestrated maker↔taker cross. + + Crosses at oracle, sized to `min(|maker_qty|, |taker_qty|)` — the + overlap that can be flattened with a single peer-to-peer match. + Unmirrored excess (one side has more than the other) is logged but + not auto-corrected, since closing it would require a third + counterparty that we don't orchestrate. + + `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) + logger.info(f" [{label}] maker (account {maker.account_id}): {maker_qty} {symbol}") + logger.info(f" [{label}] taker (account {taker.account_id}): {taker_qty} {symbol}") + + # Peer-to-peer flatten only works when one side is long and the other + # is short — only then can the cross reduce both positions simultaneously. + # Same-sign or zero-on-one-side cases need an external counterparty we + # don't orchestrate; skip them. + if maker_qty == 0 or taker_qty == 0: + logger.info(f" [{label}] one side already flat; nothing to cross") + return + if (maker_qty > 0) == (taker_qty > 0): + logger.warning( + f" [{label}] both accounts on the same side " + f"(maker {maker_qty:+}, taker {taker_qty:+}); cannot orchestrate flatten" + ) + return + + flatten_qty_raw = min(abs(maker_qty), abs(taker_qty)) + if flatten_qty_raw < min_qty: + logger.info(f" [{label}] overlap below min_qty; nothing to cross") + return + flatten_qty = flatten_qty_raw.quantize(qty_step) + + if maker_qty + taker_qty != Decimal("0"): + logger.warning( + f" [{label}] positions not perfectly mirrored " + f"(sum {maker_qty + taker_qty:+}); flattening overlap of {flatten_qty}" + ) + + # Long side places GTC SELL; short side crosses with IOC BUY (reduce_only). + if maker_qty > 0: + side_a, side_b = maker, taker + else: + side_a, side_b = taker, maker + + oracle_price = Decimal(str(await maker.data.current_price(symbol))) + cross_price = oracle_price.quantize(Decimal("0.01")) + + logger.info( + f" [{label}] flatten: account {side_a.account_id} GTC SELL {flatten_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(flatten_qty), + price=str(cross_price), + side_a_is_buy=False, + ) + if ok: + logger.info(f" [{label}] ✅ flatten completed") + else: + logger.warning(f" [{label}] flatten did not complete cleanly") + except (ApiException, OSError, RuntimeError) as e: + logger.warning(f" [{label}] flatten threw {type(e).__name__}: {e}") + + +@pytest_asyncio.fixture(loop_scope="session", scope="session") +async def perp_position_guard( # pylint: disable=redefined-outer-name + perp_maker_tester_session, perp_taker_tester_session +): + """Session-scoped guard that drives both perp accounts' positions to + zero at session start AND session end via orchestrated maker↔taker + crosses. + + Inspired by `spot_balance_guard` but with a perp-specific baseline: + spot wants to *preserve* asset balances (delta-restoration), perps + want positions=0 (since open positions accrue funding and bear + market risk between runs). So this guard flattens to zero rather + than restoring deltas. + + Opt-in via `perp_maker_tester` / `perp_taker_tester` (the function- + scoped wrappers), which transitively request this guard. Spot-only + sessions don't trigger it, so missing PERP_ACCOUNT_ID_* env vars + don't tank the suite. + + Cleanup uses orchestrated GTC/IOC pairs because perpOB has no AMM + counterparty for one-sided closes. Mirrored debris (maker -X, taker + +X — the common case after maker/taker fills) flattens cleanly. + Unmirrored excess is logged but not corrected. + + Running at BOTH start and end is intentional: end cleanup may not + run if the session crashes, so the start sweep guarantees that + every run begins from a known-clean state regardless of how the + previous run terminated. + """ + 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). + 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)) + + logger.info("=" * 60) + logger.info(f"📍 PERP POSITION GUARD: pre-session flatten of {symbol}") + logger.info("=" * 60) + + await _flatten_to_zero( + maker=perp_maker_tester_session, + taker=perp_taker_tester_session, + symbol=symbol, + min_qty=min_qty, + qty_step=qty_step, + label="session start", + ) + + yield + + logger.info("=" * 60) + logger.info(f"📍 PERP POSITION GUARD: post-session flatten of {symbol}") + logger.info("=" * 60) + + try: + await _flatten_to_zero( + maker=perp_maker_tester_session, + taker=perp_taker_tester_session, + symbol=symbol, + min_qty=min_qty, + qty_step=qty_step, + label="session end", + ) + except (ApiException, OSError, RuntimeError) as e: + logger.warning(f"perp_position_guard: flatten threw {type(e).__name__}: {e}") + + # ============================================================================ # SPOT Test Configuration Fixture # ============================================================================ diff --git a/tests/helpers/builders/order_builder.py b/tests/helpers/builders/order_builder.py index fd63e5d8..037fcb99 100644 --- a/tests/helpers/builders/order_builder.py +++ b/tests/helpers/builders/order_builder.py @@ -231,8 +231,12 @@ class TriggerOrderBuilder: _symbol: str = "ETHRUSDPERP" _is_buy: bool = True + _qty: str = "0.01" _trigger_px: str = "4000.0" - _trigger_type: OrderType = field(default_factory=lambda: OrderType.TP) + _limit_px: str = "4000.0" + _trigger_type: OrderType = field(default_factory=lambda: OrderType.TAKE_PROFIT) + _reduce_only: bool | None = None + _client_order_id: int | None = None def symbol(self, symbol: str) -> TriggerOrderBuilder: """Set the trading symbol.""" @@ -254,6 +258,11 @@ def sell(self) -> TriggerOrderBuilder: self._is_buy = False return self + def qty(self, qty: str) -> TriggerOrderBuilder: + """Set the quantity to execute when the trigger fires.""" + self._qty = qty + return self + def trigger_price(self, price: str) -> TriggerOrderBuilder: """Set the trigger price.""" self._trigger_px = price @@ -264,14 +273,19 @@ def trigger_px(self, price: str) -> TriggerOrderBuilder: self._trigger_px = price return self + def limit_px(self, price: str) -> TriggerOrderBuilder: + """Set the worst-acceptable execution price after the trigger fires.""" + self._limit_px = price + return self + def take_profit(self) -> TriggerOrderBuilder: """Set trigger type to Take Profit.""" - self._trigger_type = OrderType.TP + self._trigger_type = OrderType.TAKE_PROFIT return self def stop_loss(self) -> TriggerOrderBuilder: """Set trigger type to Stop Loss.""" - self._trigger_type = OrderType.SL + self._trigger_type = OrderType.STOP_LOSS return self def tp(self) -> TriggerOrderBuilder: @@ -282,18 +296,41 @@ def sl(self) -> TriggerOrderBuilder: """Alias for stop_loss().""" return self.stop_loss() + def reduce_only(self, value: bool = True) -> TriggerOrderBuilder: + """Mark the trigger order as reduce-only (cannot increase position size).""" + self._reduce_only = value + return self + + def client_order_id(self, client_order_id: int) -> TriggerOrderBuilder: + """Set the caller-side order id used to dedupe + correlate cancels.""" + self._client_order_id = client_order_id + return self + def build(self) -> TriggerOrderParameters: """Build and return the TriggerOrderParameters.""" return TriggerOrderParameters( symbol=self._symbol, is_buy=self._is_buy, + qty=self._qty, trigger_px=self._trigger_px, + limit_px=self._limit_px, trigger_type=self._trigger_type, + reduce_only=self._reduce_only, + client_order_id=self._client_order_id, ) def copy(self) -> TriggerOrderBuilder: """Create a copy of this builder.""" builder = TriggerOrderBuilder() - for field_name in ["_symbol", "_is_buy", "_trigger_px", "_trigger_type"]: + for field_name in [ + "_symbol", + "_is_buy", + "_qty", + "_trigger_px", + "_limit_px", + "_trigger_type", + "_reduce_only", + "_client_order_id", + ]: setattr(builder, field_name, getattr(self, field_name)) return builder diff --git a/tests/helpers/liquidity_detector.py b/tests/helpers/liquidity_detector.py index b84801fa..8769fb3c 100644 --- a/tests/helpers/liquidity_detector.py +++ b/tests/helpers/liquidity_detector.py @@ -11,6 +11,8 @@ from dataclasses import dataclass, field from decimal import Decimal +import pytest + if TYPE_CHECKING: from sdk.open_api.models.depth import Depth from sdk.open_api.models.level import Level @@ -21,15 +23,22 @@ CIRCUIT_BREAKER_PCT = Decimal("0.05") # ±5% from oracle price -# Extreme prices for safe no-match orders (guaranteed never to match) -SAFE_NO_MATCH_BUY_PRICE = Decimal("10") # $10 - far below any realistic ETH price -# $1M - far above any realistic ETH price (~470× above $2k mark) while still -# keeping the order's open notional under server-side caps. At qty=min_qty -# (e.g. 0.001 ETH), $1M × 0.001 = $1,000 of open notional, comfortably below -# the whitelisted-wallet RATE_LIMIT_GTC_MAX_OPEN_NOTIONAL_WHITELISTED cap -# (currently $5,000 on staging api-executor). The earlier $10M value yielded -# $10K notional and tripped that cap on full-suite runs. -SAFE_NO_MATCH_SELL_PRICE = Decimal("1000000") +# Extreme prices for safe no-match orders (guaranteed never to match). +# +# The matching engine validates `limit_px <= MAX_PRICE` where +# MAX_PRICE = 2^49 in E9-scaled units ≈ 562_949 in real units (~$562k). +# See protos/reya-chain/crates/matching-engine/src/base/validation/rules.rs +# in reya-off-chain-monorepo. +# +# We pick $400k for SAFE_NO_MATCH_SELL_PRICE: well below MAX_PRICE so the +# ME validator accepts it, yet still far above any realistic ETH/BTC price +# (current ETH ≈ $2k, BTC ≈ $60k — would need ~7× movement to even +# approach this), so the "guaranteed no match" intent is preserved. +# At qty=min_qty this is also ~$400 open notional, well under the +# server-side GTC notional caps (the concern behind main's old $1M value, +# which is moot here since $1M would exceed the ME MAX_PRICE anyway). +SAFE_NO_MATCH_BUY_PRICE = Decimal("10") # $10 — far below any realistic ETH/BTC price +SAFE_NO_MATCH_SELL_PRICE = Decimal("400000") # $400k — below ME MAX_PRICE (~$562k), above realistic crypto prices @dataclass @@ -311,3 +320,49 @@ def log_order_book_state(state: OrderBookState) -> None: ) else: logger.info(" Asks: empty") + + +async def skip_if_external_liquidity( + data_ops: "DataOperations", + symbol: str, + oracle_price: float, + *, + reason_prefix: str = "", +) -> 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` + (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. + If somebody else's liquidity (typically a market-making bot running on + the same env) is inside ±5% of oracle, the maker's order would cross + that liquidity instead of resting on the book, and the test fails in a + way unrelated to what it's actually exercising. + + Use at the top of any perp maker/taker test, or inside helpers that + rest a maker order at near-oracle prices. + + Args: + data_ops: ReyaTester data operations, used to fetch the depth. + symbol: Market symbol (e.g. ``ETHRUSDPERP``). + oracle_price: Current oracle price; used to log circuit-breaker + bounds for context when skipping. + reason_prefix: Optional prefix to prepend to the skip message so + callers can identify which leg of the test caused the skip + (e.g. ``"_rest_maker_sell"``). + """ + detector = LiquidityDetector(oracle_price) + state = await detector.get_order_book_state(data_ops, symbol) + if not state.has_any_liquidity: + return + + log_order_book_state(state) + prefix = f"{reason_prefix}: " if reason_prefix else "" + pytest.skip( + f"{prefix}external {symbol} liquidity present " + f"(likely a market-making bot on the same env). " + "This test rests a maker order at oracle ±1% which would cross " + "any liquidity within the ±5% circuit-breaker band rather than " + "resting on the book. Rerun against a controlled environment." + ) diff --git a/tests/helpers/market_trackers.py b/tests/helpers/market_trackers.py index dfad1e77..2269b7d0 100644 --- a/tests/helpers/market_trackers.py +++ b/tests/helpers/market_trackers.py @@ -121,7 +121,7 @@ async def fetch_price( This avoids needing a full SDK client / reya_tester session. Args: - v2_api_url: The v2 API URL (e.g. https://api-cronos.reya.xyz/v2). + v2_api_url: The v2 API URL (e.g. https://api-devnet.reya-cronos.network/v2). symbol: Trading symbol (e.g. ETHRUSDPERP). timeout: Request timeout in seconds. diff --git a/tests/helpers/reya_tester/checks.py b/tests/helpers/reya_tester/checks.py index 4c6e8ad7..9470d275 100644 --- a/tests/helpers/reya_tester/checks.py +++ b/tests/helpers/reya_tester/checks.py @@ -39,7 +39,7 @@ async def open_order_created(self, order_id: Optional[str], expected_order: Orde open_order: Optional[Union[Order, AsyncOrder]] = await self._t.data.open_order(order_id) # For trigger orders (SL/TP), if not found in open orders, check WebSocket - if open_order is None and expected_order.order_type in [OrderType.SL, OrderType.TP]: + if open_order is None and expected_order.order_type in [OrderType.STOP_LOSS, OrderType.TAKE_PROFIT]: ws_order = self._t.ws.orders.get(str(order_id)) if ws_order: open_order = ws_order @@ -101,7 +101,12 @@ async def no_open_orders(self) -> None: logger.warning(f"Order {order.order_id} exists in matching engine, waiting for cancellation...") legitimate_orders.append(order) except ApiException as e: - if "Missing order" in str(e): + # Both messages mean "the matching engine has no record of this order": + # - "Missing order" — legacy AMM-era message + # - "Order not found" — perpOB-era message (CANCEL_ORDER_OTHER_ERROR) + # In either case, the order is settled/cancelled on chain but the + # API DB hasn't caught up — treat as stale, not legitimate. + if "Missing order" in str(e) or "Order not found" in str(e): logger.info(f"Order {order.order_id} is stale (doesn't exist in matching engine), ignoring") else: logger.warning(f"Unexpected error cancelling order {order.order_id}: {e}") @@ -143,8 +148,22 @@ async def position( expected_avg_entry_price: Optional[str] = None, expected_last_trade_sequence_number: Optional[int] = None, ) -> None: - """Verify position exists with expected values.""" + """Verify position exists with expected values. + + Briefly polls the API view to absorb the indexer-write lag between + on-chain settlement and the position appearing in + `/v2/wallet/{addr}/positions`. The position itself is on-chain + immediately after the fill response, but the off-chain position + view trails by ~hundreds of ms while the indexer processes the + `PassivePerpExecutionV3` event. Without this retry, fast tests + race the indexer and see no position. + """ pos = await self._t.data.position(symbol) + if pos is None: + deadline = asyncio.get_event_loop().time() + 3.0 + while pos is None and asyncio.get_event_loop().time() < deadline: + await asyncio.sleep(0.1) + pos = await self._t.data.position(symbol) if pos is None: raise RuntimeError("check_position: Position not found") @@ -186,11 +205,12 @@ async def order_execution( assert ( order_execution.symbol == expected_order.symbol ), "check_order_execution: Order execution symbol does not match" - assert ( - order_execution.account_id == expected_order.account_id - ), "check_order_execution: Order execution account ID does not match" - assert ( - order_execution.qty == expected_order.qty if expected_qty is None else expected_qty + assert expected_order.account_id in ( + order_execution.taker_account_id, + order_execution.maker_account_id, + ), "check_order_execution: Order execution account ID does not match either taker or maker" + assert order_execution.qty == ( + expected_order.qty if expected_qty is None else expected_qty ), "check_order_execution: Order execution qty does not match" assert order_execution.side == expected_order.side, "check_order_execution: Order execution side does not match" assert ( diff --git a/tests/helpers/reya_tester/data.py b/tests/helpers/reya_tester/data.py index a5fb2c4a..c67e1991 100644 --- a/tests/helpers/reya_tester/data.py +++ b/tests/helpers/reya_tester/data.py @@ -2,10 +2,14 @@ from typing import TYPE_CHECKING, Optional +import asyncio import logging +from sdk.open_api.exceptions import ApiException from sdk.open_api.models.account_balance import AccountBalance from sdk.open_api.models.depth import Depth +from sdk.open_api.models.execution_bust import ExecutionBust +from sdk.open_api.models.execution_bust_list import ExecutionBustList from sdk.open_api.models.market_definition import MarketDefinition from sdk.open_api.models.order import Order from sdk.open_api.models.perp_execution import PerpExecution @@ -13,8 +17,6 @@ from sdk.open_api.models.position import Position from sdk.open_api.models.price import Price from sdk.open_api.models.spot_execution import SpotExecution -from sdk.open_api.models.spot_execution_bust import SpotExecutionBust -from sdk.open_api.models.spot_execution_bust_list import SpotExecutionBustList from sdk.open_api.models.spot_execution_list import SpotExecutionList if TYPE_CHECKING: @@ -29,18 +31,38 @@ class DataOperations: def __init__(self, tester: "ReyaTester"): self._t = tester - async def current_price(self, symbol: str = "ETHRUSDPERP") -> str: - """Fetch current market price for a symbol.""" - price_info: Price = await self._t.client.markets.get_price(symbol) - logger.info(f"Price info: {price_info}") - current_price = price_info.oracle_price + async def current_price(self, symbol: str = "ETHRUSDPERP", max_attempts: int = 5) -> str: + """Fetch current market price for a symbol, retrying through transient price-feed gaps. - if current_price: - logger.info(f"💰 Current market price for {symbol}: ${float(current_price):.2f}") - return current_price - else: - logger.info(f"❌ Current market price for {symbol} not found") - raise RuntimeError("Current market price not found") + Devnet's price endpoint (`GET /prices/{symbol}`) occasionally returns a + momentary `400 NO_PRICES_FOUND_FOR_SYMBOL` ("Price not found") during an + oracle-push / cache-refresh race. This is a precondition read for many + tests, so a transient blip here would otherwise fail unrelated tests. + Retry a few times with a short backoff; a sustained outage still raises. + """ + last_exc: Optional[Exception] = None + for attempt in range(max_attempts): + try: + price_info: Price = await self._t.client.markets.get_price(symbol) + logger.info(f"Price info: {price_info}") + current_price = price_info.oracle_price + if current_price: + logger.info(f"💰 Current market price for {symbol}: ${float(current_price):.2f}") + return current_price + logger.info(f"❌ Current market price for {symbol} missing oracle_price") + except ApiException as e: + # Only the transient price-feed gap is retryable; anything else is a real error. + error_text = str(e) + if "NO_PRICES_FOUND_FOR_SYMBOL" not in error_text and "Price not found" not in error_text: + raise + last_exc = e + logger.warning( + f"⚠️ price feed gap for {symbol} (attempt {attempt + 1}/{max_attempts}): " + "NO_PRICES_FOUND_FOR_SYMBOL; retrying in 0.3s" + ) + await asyncio.sleep(0.3) + + raise RuntimeError(f"Current market price for {symbol} unavailable after {max_attempts} attempts") from last_exc async def positions(self) -> dict[str, Position]: """Get all current positions.""" @@ -130,21 +152,19 @@ async def open_orders(self) -> list[Order]: """Get all open orders.""" return await self._t.client.get_open_orders() - async def spot_execution_busts(self) -> list[SpotExecutionBust]: - """Get spot execution busts for this wallet. + async def execution_busts(self) -> list[ExecutionBust]: + """Get execution busts (failed fills) for this wallet, across spot and perp. - Returns list of SpotExecutionBust objects (may be empty if no busts exist). + Returns list of ExecutionBust objects (may be empty). """ - bust_list: SpotExecutionBustList = await self._t.client.get_spot_execution_busts() + bust_list: ExecutionBustList = await self._t.client.get_execution_busts() return bust_list.data if bust_list.data else [] - async def market_spot_execution_busts(self, symbol: str) -> list[SpotExecutionBust]: - """Get spot execution busts for a specific market. + async def market_execution_busts(self, symbol: str) -> list[ExecutionBust]: + """Get execution busts for a specific market (spot or perp). Args: - symbol: Trading symbol (e.g., "WETHRUSD"). - - Returns list of SpotExecutionBust objects (may be empty if no busts exist). + symbol: Trading symbol (e.g., ``WETHRUSD`` or ``ETHRUSDPERP``). """ - bust_list: SpotExecutionBustList = await self._t.client.markets.get_market_spot_execution_busts(symbol=symbol) + bust_list: ExecutionBustList = await self._t.client.markets.get_market_execution_busts(symbol=symbol) return bust_list.data if bust_list.data else [] diff --git a/tests/helpers/reya_tester/matchers.py b/tests/helpers/reya_tester/matchers.py index 3a8e2a2d..1dbb5b39 100644 --- a/tests/helpers/reya_tester/matchers.py +++ b/tests/helpers/reya_tester/matchers.py @@ -47,7 +47,7 @@ def match_perp( """Match a perp execution against expected order values. Note: PerpExecution does NOT have order_id, so we match by: - - account_id, symbol, side, qty + - account_id (taker or maker side), symbol, side, qty Args: execution: The perp execution to check. @@ -57,7 +57,8 @@ def match_perp( Returns: True if execution matches expected values. """ - if execution.account_id != expected.account_id: + # Expected order's account is on one side of the trade — match either side. + if expected.account_id not in (execution.taker_account_id, execution.maker_account_id): return False if execution.symbol != expected.symbol: return False diff --git a/tests/helpers/reya_tester/perp_trade_context.py b/tests/helpers/reya_tester/perp_trade_context.py index 3c24a7fe..a4380741 100644 --- a/tests/helpers/reya_tester/perp_trade_context.py +++ b/tests/helpers/reya_tester/perp_trade_context.py @@ -141,7 +141,8 @@ def _find_ws_execution( # Debug: log why it didn't match logger.debug( f"Execution seq={seq} didn't match: " - f"account_id={execution.account_id} vs {expected_order.account_id}, " + f"taker/maker_account_id=({execution.taker_account_id}, {execution.maker_account_id}) " + f"vs {expected_order.account_id}, " f"symbol={execution.symbol} vs {expected_order.symbol}, " f"side={_get_enum_value(execution.side)} vs {_get_enum_value(expected_order.side)}, " f"qty={execution.qty} vs {expected_qty or expected_order.qty}" @@ -172,7 +173,8 @@ def _matches_order( expected_qty: Optional[str] = None, ) -> bool: """Check if execution matches expected order parameters.""" - if execution.account_id != expected.account_id: + # Expected order's account is on one side of the trade — accept either. + if expected.account_id not in (execution.taker_account_id, execution.maker_account_id): return False if execution.symbol != expected.symbol: return False diff --git a/tests/helpers/reya_tester/positions.py b/tests/helpers/reya_tester/positions.py index 38c29c9d..3eba6140 100644 --- a/tests/helpers/reya_tester/positions.py +++ b/tests/helpers/reya_tester/positions.py @@ -27,15 +27,20 @@ def __init__(self, tester: "ReyaTester"): self._t = tester async def close_all(self, fail_if_none: bool = True) -> None: - """Close all open positions.""" + """Best-effort: try to flatten any open positions. + + Under perpOB (peer-to-peer matching, no AMM), a reduce-only IOC only + closes a position if there's a counterparty on the book at a crossing + price. Without orchestration between maker and taker testers, the + cleanup IOC may end up CANCELLED with the position still open — that's + expected, not a test failure. Tests that genuinely need a clean + position slate should verify it explicitly via assertions on + `positions()` rather than relying on this helper. + """ try: positions = await self._t.data.positions() except ApiException as e: logger.warning(f"Failed to get positions (API may not have market trackers in Redis): {e}") - if fail_if_none: - logger.warning( - "Ignoring positions error since fail_if_none=True means we don't require positions to exist" - ) return None if len(positions) == 0: @@ -51,40 +56,73 @@ async def close_all(self, fail_if_none: bool = True) -> None: logger.info(f"Position {symbol} already closed, skipping") continue - price_with_offset = 0 if current_position.side == Side.B else 1000000000000 + # Depth check: under perpOB, a reduce-only IOC only fills against + # resting counterparties. If the side we'd cross is empty, sending + # the IOC just burns a nonce and produces a CANCELLED order. Skip + # in that case — the orchestrated `perp_position_guard` fixture is + # responsible for cleaning up genuine residue between sessions. + close_is_buy = current_position.side != Side.B + try: + depth = await self._t.data.market_depth(symbol) + except ApiException as e: + logger.warning(f"Failed to fetch market depth for {symbol}: {e} — attempting IOC anyway") + depth = None + + if depth is not None: + opposite_levels = depth.asks if close_is_buy else depth.bids + if not opposite_levels: + logger.info( + f"close_all: skipping reduce-only IOC for {symbol} — " + f"no resting {'asks' if close_is_buy else 'bids'} to cross" + ) + continue + + # Sentinel prices anchored to oracle so the reduce-only IOC always + # crosses any resting order without violating ME bounds. The ME + # rejects `limit_px <= 0` and `limit_px > 2^64 / 1e9 ≈ 1.844e10`, + # so the legacy `0` / `1e12` sentinels don't work under perpOB. + # 0.5x / 1.5x oracle is wide enough to cross anything the test + # suite places (which sits within ±5% of oracle). + oracle_price = float(await self._t.data.current_price(symbol)) + close_price = oracle_price * (0.5 if current_position.side == Side.B else 1.5) limit_order_params = LimitOrderParameters( symbol=symbol, - is_buy=not (current_position.side == Side.B), - limit_px=str(price_with_offset), + is_buy=close_is_buy, + limit_px=str(round(close_price, 2)), qty=str(current_position.qty), time_in_force=TimeInForce.IOC, reduce_only=True, ) logger.debug(f"Order params: {limit_order_params}") - order_id = await self._t.orders.create_limit(limit_order_params) - assert order_id is None + try: + await self._t.orders.create_limit(limit_order_params) + except ApiException as e: + logger.warning(f"Reduce-only close attempt failed for {symbol}: {e}") + continue - # Wait for positions to be actually closed + # Wait briefly for positions to clear — short timeout because under + # perpOB the cleanup IOC may be CANCELLED with position still open + # (no counterparty), and there's no point waiting in that case. start_time = time.time() - timeout = 10 - + timeout = 3 while time.time() - start_time < timeout: position_after = await self._t.data.positions() if len(position_after) == 0: - elapsed_time = time.time() - start_time - logger.info(f"✅ All positions closed successfully (took {elapsed_time:.2f}s)") + logger.info(f"✅ All positions closed (took {time.time() - start_time:.2f}s)") return + await asyncio.sleep(0.1) - logger.debug(f"Still have {len(position_after)} positions, waiting...") - await asyncio.sleep(0.05) - - # Timeout reached + # Timeout reached — best-effort, log and return rather than failing + # the test fixture. See class docstring for rationale. position_after = await self._t.data.positions() if len(position_after) > 0: - logger.error(f"Failed to close positions after {timeout}s timeout: {position_after}") - assert False + logger.warning( + f"close_all: {len(position_after)} position(s) remain open after {timeout}s — " + "under perpOB, reduce-only IOC requires a resting counterparty. " + f"Symbols: {list(position_after.keys())}" + ) async def setup( self, @@ -141,9 +179,13 @@ async def close(self, symbol: str, qty: str = "0.01") -> None: is_buy = position.side == Side.A + # See close_all comment: oracle-anchored sentinel within ME bounds. + oracle_price = float(await self._t.data.current_price(symbol)) + close_price = oracle_price * (1.5 if is_buy else 0.5) + close_order_params = LimitOrderParameters( symbol=symbol, - limit_px="0", + limit_px=str(round(close_price, 2)), is_buy=is_buy, time_in_force=TimeInForce.IOC, qty=qty, @@ -167,9 +209,13 @@ async def flip(self, symbol: str, current_qty: str = "0.01", flip_qty: str = "0. is_buy = position.side == Side.A + # See close_all comment: oracle-anchored sentinel within ME bounds. + oracle_price = float(await self._t.data.current_price(symbol)) + flip_price = oracle_price * (1.5 if is_buy else 0.5) + flip_order_params = LimitOrderParameters( symbol=symbol, - limit_px="0", + limit_px=str(round(flip_price, 2)), is_buy=is_buy, time_in_force=TimeInForce.IOC, qty=flip_qty, diff --git a/tests/helpers/reya_tester/tester.py b/tests/helpers/reya_tester/tester.py index 71eccefe..6c9af890 100644 --- a/tests/helpers/reya_tester/tester.py +++ b/tests/helpers/reya_tester/tester.py @@ -59,30 +59,44 @@ class ReyaTester: tester.ws.get_balance_update_count() """ - def __init__(self, spot_account_number: Optional[int] = None): + def __init__( + self, + spot_account_number: Optional[int] = None, + perp_account_number: Optional[int] = None, + ): """ Initialize ReyaTester with specified account configuration. Args: - spot_account_number: Optional spot account to use (1 or 2) for spot tests. - If None, uses default PERP_ACCOUNT_ID_1, PERP_PRIVATE_KEY_1, PERP_WALLET_ADDRESS_1. - If 1, uses SPOT_ACCOUNT_ID_1, SPOT_PRIVATE_KEY_1, SPOT_WALLET_ADDRESS_1. - If 2, uses SPOT_ACCOUNT_ID_2, SPOT_PRIVATE_KEY_2, SPOT_WALLET_ADDRESS_2. + spot_account_number: Optional spot account (1 or 2) for spot tests. + If set, uses SPOT_ACCOUNT_ID_, SPOT_PRIVATE_KEY_, SPOT_WALLET_ADDRESS_. + perp_account_number: Optional perp account (1 or 2) for perp orderbook + maker/taker tests. If set, uses PERP_ACCOUNT_ID_, PERP_PRIVATE_KEY_, + PERP_WALLET_ADDRESS_. ``perp_account_number=1`` is also the default + when both args are None (backwards compat). + + Exactly one of spot_account_number / perp_account_number may be set; if both + are None, the legacy default is PERP_ACCOUNT_ID_1. """ load_dotenv() + if spot_account_number is not None and perp_account_number is not None: + raise ValueError("spot_account_number and perp_account_number are mutually exclusive") + # Track if this is a spot account (cannot trade perps) self._is_spot_account = spot_account_number is not None self._spot_account_number = spot_account_number + self._perp_account_number = perp_account_number - if spot_account_number is None: - # Default - use standard config (PERP_ACCOUNT_ID_1, PERP_PRIVATE_KEY_1, PERP_WALLET_ADDRESS_1) + if spot_account_number is None and perp_account_number in (None, 1): + # Default — PERP_ACCOUNT_ID_1 via TradingConfig.from_env() self.client = ReyaTradingClient() elif spot_account_number in (1, 2): - # Spot account - create client with explicit spot account config self.client = self._create_client_for_spot_account(spot_account_number) + elif perp_account_number == 2: + self.client = self._create_client_for_perp_account(perp_account_number) else: - raise ValueError(f"Invalid spot_account_number: {spot_account_number}. Must be None, 1, or 2.") + raise ValueError(f"Invalid account selection: spot={spot_account_number} perp={perp_account_number}") # Store account properties - these must be set for tests to work assert self.client is not None, "Client must be initialized" @@ -109,36 +123,52 @@ def __init__(self, spot_account_number: Optional[int] = None): def _create_client_for_spot_account(self, spot_account_number: int) -> ReyaTradingClient: """Create a ReyaTradingClient configured for a spot account.""" - account_id = os.environ.get(f"SPOT_ACCOUNT_ID_{spot_account_number}") - private_key = os.environ.get(f"SPOT_PRIVATE_KEY_{spot_account_number}") - wallet_address = os.environ.get(f"SPOT_WALLET_ADDRESS_{spot_account_number}") + return self._create_client_for_account("SPOT", spot_account_number) + + def _create_client_for_perp_account(self, perp_account_number: int) -> ReyaTradingClient: + """Create a ReyaTradingClient configured for the secondary perp account. + + PERP_ACCOUNT_ID_2 / PERP_PRIVATE_KEY_2 / PERP_WALLET_ADDRESS_2 unlock perp + orderbook maker/taker tests; under perpOB every fill needs a counterparty + on the opposite side of the book. + """ + return self._create_client_for_account("PERP", perp_account_number) + + def _create_client_for_account(self, prefix: str, account_number: int) -> ReyaTradingClient: + """Build a client from {PREFIX}_ACCOUNT_ID_N env vars (PREFIX in {SPOT, PERP}).""" + account_id = os.environ.get(f"{prefix}_ACCOUNT_ID_{account_number}") + private_key = os.environ.get(f"{prefix}_PRIVATE_KEY_{account_number}") + wallet_address = os.environ.get(f"{prefix}_WALLET_ADDRESS_{account_number}") if not all([account_id, private_key, wallet_address]): logger.warning( - f"Spot Account {spot_account_number} not fully configured. Missing one of: SPOT_ACCOUNT_ID_{spot_account_number}, SPOT_PRIVATE_KEY_{spot_account_number}, SPOT_WALLET_ADDRESS_{spot_account_number}" + f"{prefix} Account {account_number} not fully configured. Missing one of: " + f"{prefix}_ACCOUNT_ID_{account_number}, {prefix}_PRIVATE_KEY_{account_number}, " + f"{prefix}_WALLET_ADDRESS_{account_number}" ) - # Return a client with None values - tests will skip if needed return ReyaTradingClient() - # Get base config to inherit api_url and chain_id base_client = ReyaTradingClient() base_config = base_client.config - # Create new config with spot account values if wallet_address is None: - raise ValueError("wallet_address is required for spot account") + raise ValueError(f"wallet_address is required for {prefix} account") if account_id is None: - raise ValueError("account_id is required for spot account") - spot_config = TradingConfig( + raise ValueError(f"account_id is required for {prefix} account") + config = TradingConfig( api_url=base_config.api_url, chain_id=base_config.chain_id, owner_wallet_address=wallet_address, private_key=private_key, account_id=int(account_id), + # Carry env-var-driven overrides from the base config so secondary + # accounts sign with the same OrdersGateway domain and exchange id + # as account 1 — otherwise they fall back to chain-id defaults that + # may not match the deployment (e.g. devnet1). + orders_gateway_address=base_config.orders_gateway_address, + dex_id_override=base_config.dex_id_override, ) - - # Create client with the spot config directly - return ReyaTradingClient(config=spot_config) + return ReyaTradingClient(config=config) async def setup(self) -> None: """Set up WebSocket connection for trade monitoring.""" @@ -185,6 +215,11 @@ def spot_account_number(self) -> Optional[int]: """Get the spot account number (1 or 2) if this is a spot account, None otherwise.""" return self._spot_account_number + @property + def perp_account_number(self) -> Optional[int]: + """Get the perp account number (1 or 2) if explicitly selected.""" + return self._perp_account_number + @property def is_spot_account(self) -> bool: """Check if this tester is configured for a spot account.""" diff --git a/tests/helpers/reya_tester/waiters.py b/tests/helpers/reya_tester/waiters.py index db29c83f..fbddc3dd 100644 --- a/tests/helpers/reya_tester/waiters.py +++ b/tests/helpers/reya_tester/waiters.py @@ -10,11 +10,11 @@ import time from sdk.async_api.order import Order as AsyncOrder +from sdk.open_api.models.execution_bust import ExecutionBust from sdk.open_api.models.order import Order from sdk.open_api.models.order_status import OrderStatus from sdk.open_api.models.perp_execution import PerpExecution from sdk.open_api.models.spot_execution import SpotExecution -from sdk.open_api.models.spot_execution_bust import SpotExecutionBust from sdk.open_api.models.spot_execution_list import SpotExecutionList from tests.helpers.validators import validate_order_fields, validate_spot_execution_fields @@ -469,12 +469,12 @@ async def for_balance_updates( new_count = len(self._t.ws.balance_updates) - initial_count raise RuntimeError(f"Expected at least {min_updates} balance update(s), got {new_count} after {timeout}s") - async def for_spot_execution_bust( + async def for_execution_bust( self, order_id: Optional[str] = None, timeout: int = 15, - ) -> SpotExecutionBust: - """Wait for a spot execution bust event via both WS and REST. + ) -> ExecutionBust: + """Wait for an execution bust event via both WS and REST (spot or perp). Polls the WS EventStore for a bust matching the given order_id (as either taker order_id or maker_order_id). Then confirms via REST. @@ -482,11 +482,8 @@ async def for_spot_execution_bust( Args: order_id: Order ID to match (taker or maker side). If None, waits for any bust. timeout: Maximum time to wait in seconds. - - Returns: - The REST SpotExecutionBust object. """ - logger.info(f"⏳ Waiting for spot execution bust (order_id={order_id})...") + logger.info(f"⏳ Waiting for execution bust (order_id={order_id})...") start_time = time.time() ws_bust = None @@ -497,27 +494,25 @@ async def for_spot_execution_bust( if ws_bust is None: if order_id is not None: # Try matching as taker order_id (primary key) - found = self._t.ws.spot_execution_busts.get(str(order_id)) + found = self._t.ws.execution_busts.get(str(order_id)) if found is None: # Try matching as maker_order_id - found = self._t.ws.spot_execution_busts.find_last( - lambda b: str(b.maker_order_id) == str(order_id) - ) + found = self._t.ws.execution_busts.find_last(lambda b: str(b.maker_order_id) == str(order_id)) if found: elapsed = time.time() - start_time logger.info(f" ✅ Bust confirmed via WS: order_id={found.order_id} (took {elapsed:.2f}s)") ws_bust = found else: # Wait for any bust - if len(self._t.ws.spot_execution_busts) > 0: - ws_bust = self._t.ws.spot_execution_busts.last + if len(self._t.ws.execution_busts) > 0: + ws_bust = self._t.ws.execution_busts.last elapsed = time.time() - start_time if ws_bust is not None: logger.info(f" ✅ Bust confirmed via WS: order_id={ws_bust.order_id} (took {elapsed:.2f}s)") # Once WS confirms, verify via REST if ws_bust is not None and rest_bust is None: - busts = await self._t.data.spot_execution_busts() + busts = await self._t.data.execution_busts() for bust in busts: if str(bust.order_id) == str(ws_bust.order_id): elapsed = time.time() - start_time @@ -531,6 +526,6 @@ async def for_spot_execution_bust( await asyncio.sleep(0.2) raise RuntimeError( - f"Spot execution bust not confirmed after {timeout}s, " + f"Execution bust not confirmed after {timeout}s, " f"order_id={order_id}, ws: {ws_bust is not None}, rest: {rest_bust is not None}" ) diff --git a/tests/helpers/reya_tester/websocket.py b/tests/helpers/reya_tester/websocket.py index badd6d54..321f04c4 100644 --- a/tests/helpers/reya_tester/websocket.py +++ b/tests/helpers/reya_tester/websocket.py @@ -6,7 +6,9 @@ Uses EventStore for unified state tracking across all event types. """ -from typing import TYPE_CHECKING, Optional, Union +from __future__ import annotations + +from typing import TYPE_CHECKING import logging from collections.abc import Mapping @@ -15,8 +17,9 @@ from sdk.async_api.account_balance import AccountBalance as AsyncAccountBalance from sdk.async_api.account_balance_update_payload import AccountBalanceUpdatePayload from sdk.async_api.depth import Depth +from sdk.async_api.execution_bust import ExecutionBust as AsyncExecutionBust from sdk.async_api.market_depth_update_payload import MarketDepthUpdatePayload -from sdk.async_api.market_spot_execution_bust_update_payload import MarketSpotExecutionBustUpdatePayload +from sdk.async_api.market_execution_bust_update_payload import MarketExecutionBustUpdatePayload from sdk.async_api.market_spot_execution_update_payload import MarketSpotExecutionUpdatePayload from sdk.async_api.order import Order as AsyncOrder from sdk.async_api.order_change_update_payload import OrderChangeUpdatePayload @@ -24,10 +27,9 @@ from sdk.async_api.position import Position as AsyncPosition from sdk.async_api.position_update_payload import PositionUpdatePayload from sdk.async_api.spot_execution import SpotExecution as AsyncSpotExecution -from sdk.async_api.spot_execution_bust import SpotExecutionBust as AsyncSpotExecutionBust from sdk.async_api.subscribed_message_payload import SubscribedMessagePayload +from sdk.async_api.wallet_execution_bust_update_payload import WalletExecutionBustUpdatePayload from sdk.async_api.wallet_perp_execution_update_payload import WalletPerpExecutionUpdatePayload -from sdk.async_api.wallet_spot_execution_bust_update_payload import WalletSpotExecutionBustUpdatePayload from sdk.async_api.wallet_spot_execution_update_payload import WalletSpotExecutionUpdatePayload from sdk.open_api.models.account_balance import AccountBalance as OpenApiAccountBalance from sdk.reya_websocket import WebSocketMessage @@ -47,7 +49,7 @@ class WebSocketState: All stores use the same pattern for consistency between perp/spot. """ - def __init__(self, tester: "ReyaTester"): + def __init__(self, tester: ReyaTester): self._t = tester # Unified state tracking using EventStore @@ -61,9 +63,9 @@ def __init__(self, tester: "ReyaTester"): self.orders: EventStore[AsyncOrder] = EventStore(key_fn=lambda x: str(x.order_id)) self.balances: EventStore[AsyncAccountBalance] = EventStore(key_fn=lambda x: x.asset) - # Bust stores - self.spot_execution_busts: EventStore[AsyncSpotExecutionBust] = EventStore(key_fn=lambda x: str(x.order_id)) - self.market_spot_execution_busts: dict[str, EventStore[AsyncSpotExecutionBust]] = {} + # Bust stores (unified spot + perp) + self.execution_busts: EventStore[AsyncExecutionBust] = EventStore(key_fn=lambda x: str(x.order_id)) + self.market_execution_busts: dict[str, EventStore[AsyncExecutionBust]] = {} # Market-level stores (by symbol) self.market_spot_executions: dict[str, EventStore[AsyncSpotExecution]] = {} @@ -79,12 +81,12 @@ def order_changes(self) -> EventStore[AsyncOrder]: return self.orders @property - def last_trade(self) -> Optional[AsyncPerpExecution]: + def last_trade(self) -> AsyncPerpExecution | None: """Backward compatibility: get last perp execution.""" return self.perp_executions.last @last_trade.setter - def last_trade(self, value: Optional[AsyncPerpExecution]) -> None: + def last_trade(self, value: AsyncPerpExecution | None) -> None: """Backward compatibility: setting last_trade clears and adds.""" if value is None: self.perp_executions.clear() @@ -92,12 +94,12 @@ def last_trade(self, value: Optional[AsyncPerpExecution]) -> None: self.perp_executions.add(value) @property - def last_spot_execution(self) -> Optional[AsyncSpotExecution]: + def last_spot_execution(self) -> AsyncSpotExecution | None: """Backward compatibility: get last spot execution.""" return self.spot_executions.last @last_spot_execution.setter - def last_spot_execution(self, value: Optional[AsyncSpotExecution]) -> None: + def last_spot_execution(self, value: AsyncSpotExecution | None) -> None: """Backward compatibility: setting last_spot_execution clears and adds.""" if value is None: self.spot_executions.clear() @@ -118,13 +120,13 @@ def clear(self) -> None: """Clear all WebSocket state.""" self.perp_executions.clear() self.spot_executions.clear() - self.spot_execution_busts.clear() + self.execution_busts.clear() self.balance_updates.clear() self.positions.clear() self.orders.clear() self.balances.clear() self.market_spot_executions.clear() - self.market_spot_execution_busts.clear() + self.market_execution_busts.clear() self.depth.clear() def clear_balance_updates(self) -> None: @@ -164,29 +166,29 @@ def subscribe_to_market_spot_executions(self, symbol: str) -> None: self._t.websocket.market.spot_executions(symbol).subscribe() logger.info(f"Subscribed to market spot executions for {symbol}") - def subscribe_to_market_spot_execution_busts(self, symbol: str) -> None: - """Subscribe to market-level spot execution busts for a specific symbol.""" + def subscribe_to_market_execution_busts(self, symbol: str) -> None: + """Subscribe to market-level execution busts for a specific symbol (spot or perp).""" if self._t.websocket is None: raise RuntimeError("WebSocket not connected - call setup() first") - self._t.websocket.market.spot_execution_busts(symbol).subscribe() - logger.info(f"Subscribed to market spot execution busts for {symbol}") + self._t.websocket.market.execution_busts(symbol).subscribe() + logger.info(f"Subscribed to market execution busts for {symbol}") - def clear_spot_execution_busts(self) -> None: - """Clear the list of spot execution busts.""" - self.spot_execution_busts.clear() - logger.debug("Cleared WebSocket spot execution busts") + def clear_execution_busts(self) -> None: + """Clear the list of execution busts.""" + self.execution_busts.clear() + logger.debug("Cleared WebSocket execution busts") - def clear_market_spot_execution_busts(self, symbol: Optional[str] = None) -> None: - """Clear market spot execution busts. If symbol provided, clear only that symbol.""" + def clear_market_execution_busts(self, symbol: str | None = None) -> None: + """Clear market execution busts. If symbol provided, clear only that symbol.""" if symbol: - if symbol in self.market_spot_execution_busts: - self.market_spot_execution_busts[symbol].clear() - logger.debug(f"Cleared market spot execution busts for {symbol}") + if symbol in self.market_execution_busts: + self.market_execution_busts[symbol].clear() + logger.debug(f"Cleared market execution busts for {symbol}") else: - self.market_spot_execution_busts.clear() - logger.debug("Cleared all market spot execution busts") + self.market_execution_busts.clear() + logger.debug("Cleared all market execution busts") - def clear_market_spot_executions(self, symbol: Optional[str] = None) -> None: + def clear_market_spot_executions(self, symbol: str | None = None) -> None: """Clear market spot executions. If symbol provided, clear only that symbol.""" if symbol: if symbol in self.market_spot_executions: @@ -202,7 +204,7 @@ def on_open(self, ws) -> None: ws.wallet.perp_executions(self._t.owner_wallet_address).subscribe() ws.wallet.spot_executions(self._t.owner_wallet_address).subscribe() - ws.wallet.spot_execution_busts(self._t.owner_wallet_address).subscribe() + ws.wallet.execution_busts(self._t.owner_wallet_address).subscribe() ws.wallet.order_changes(self._t.owner_wallet_address).subscribe() ws.wallet.positions(self._t.owner_wallet_address).subscribe() ws.wallet.balances(self._t.owner_wallet_address).subscribe() @@ -227,9 +229,9 @@ def on_message(self, _ws, message: WebSocketMessage) -> None: elif isinstance(message, (MarketSpotExecutionUpdatePayload, WalletSpotExecutionUpdatePayload)): self._handle_spot_executions(message) - # Handle spot execution busts (market or wallet level) - elif isinstance(message, (MarketSpotExecutionBustUpdatePayload, WalletSpotExecutionBustUpdatePayload)): - self._handle_spot_execution_busts(message) + # Handle execution busts (market or wallet level, spot or perp) + elif isinstance(message, (MarketExecutionBustUpdatePayload, WalletExecutionBustUpdatePayload)): + self._handle_execution_busts(message) # Handle order changes elif isinstance(message, OrderChangeUpdatePayload): @@ -286,7 +288,8 @@ def _handle_perp_executions(self, message: WalletPerpExecutionUpdatePayload) -> for trade in message.data: logger.info( f"📊 Perp execution received: seq={trade.sequence_number}, " - f"account_id={trade.account_id}, symbol={trade.symbol}, " + f"taker/maker_account_id=({trade.taker_account_id}, {trade.maker_account_id}), " + f"symbol={trade.symbol}, " f"side={trade.side.value if hasattr(trade.side, 'value') else trade.side}, qty={trade.qty}" ) self.perp_executions.add(trade) @@ -307,27 +310,27 @@ def _handle_spot_executions( else: self.spot_executions.add(exec_data) - def _handle_spot_execution_busts( - self, message: MarketSpotExecutionBustUpdatePayload | WalletSpotExecutionBustUpdatePayload + def _handle_execution_busts( + self, message: MarketExecutionBustUpdatePayload | WalletExecutionBustUpdatePayload ) -> None: - """Handle spot execution bust updates (market or wallet level).""" + """Handle execution bust updates (market or wallet level, spot or perp).""" is_market_channel = "/market/" in message.channel for bust_data in message.data: logger.info( - f"💥 Spot execution bust received: symbol={bust_data.symbol}, " + f"💥 Execution bust received: symbol={bust_data.symbol}, " f"order_id={bust_data.order_id}, maker_order_id={bust_data.maker_order_id}, " f"side={bust_data.side.value if hasattr(bust_data.side, 'value') else bust_data.side}, " f"qty={bust_data.qty}, reason={bust_data.reason}" ) if is_market_channel: symbol = message.channel.split("/")[3] - if symbol not in self.market_spot_execution_busts: - self.market_spot_execution_busts[symbol] = EventStore(key_fn=lambda x: str(x.order_id)) - self.market_spot_execution_busts[symbol].add(bust_data) - logger.debug(f"Added market spot execution bust for {symbol}: {bust_data.order_id}") + if symbol not in self.market_execution_busts: + self.market_execution_busts[symbol] = EventStore(key_fn=lambda x: str(x.order_id)) + self.market_execution_busts[symbol].add(bust_data) + logger.debug(f"Added market execution bust for {symbol}: {bust_data.order_id}") else: - self.spot_execution_busts.add(bust_data) + self.execution_busts.add(bust_data) def _handle_order_changes(self, message: OrderChangeUpdatePayload) -> None: """Handle order change updates.""" @@ -390,10 +393,10 @@ def _handle_balance_updates(self, message: AccountBalanceUpdatePayload) -> None: def verify_spot_trade_balance_changes( self, - maker_initial_balances: Mapping[str, Union[AsyncAccountBalance, OpenApiAccountBalance]], - maker_final_balances: Mapping[str, Union[AsyncAccountBalance, OpenApiAccountBalance]], - taker_initial_balances: Mapping[str, Union[AsyncAccountBalance, OpenApiAccountBalance]], - taker_final_balances: Mapping[str, Union[AsyncAccountBalance, OpenApiAccountBalance]], + maker_initial_balances: Mapping[str, AsyncAccountBalance | OpenApiAccountBalance], + maker_final_balances: Mapping[str, AsyncAccountBalance | OpenApiAccountBalance], + taker_initial_balances: Mapping[str, AsyncAccountBalance | OpenApiAccountBalance], + taker_final_balances: Mapping[str, AsyncAccountBalance | OpenApiAccountBalance], base_asset: str, quote_asset: str, qty: str, diff --git a/tests/parity/README.md b/tests/parity/README.md new file mode 100644 index 00000000..d23db4d6 --- /dev/null +++ b/tests/parity/README.md @@ -0,0 +1,48 @@ +# EIP-712 signature parity (TS ↔ Py) + +Confirms the Python `sign_order` / `sign_cancel_order` / `sign_mass_cancel` +helpers produce byte-identical signatures to the canonical TypeScript impl in +[`reya-off-chain-monorepo/packages/common/src/transactions/sign.ts`](https://github.com/Reya-Labs/reya-off-chain-monorepo/blob/feat/perpOB/packages/common/src/transactions/sign.ts). + +## How it works + +- [sign_ts.mjs](sign_ts.mjs) signs three v2.3.0 envelopes (Order, OrderCancel, + MassCancel) with a fixed hardhat test key + fixed payload using ethers v6's + `signTypedData`. Outputs a JSON dict of `{order, order_cancel, mass_cancel}` → + hex signatures. +- [test_signature_parity.py](test_signature_parity.py) hardcodes those hex + values and asserts the Python helpers produce them for the same inputs. + +A drift in either direction (Python helper or canonical TS impl) breaks the +test loudly. + +## Running the Python side + +From the SDK repo root, in the poetry env: + +```bash +poetry run pytest tests/parity/test_signature_parity.py -v +``` + +## Regenerating the expected hex (when TS evolves) + +```bash +cd tests/parity +npm install # one-time; pulls ethers v6 into ./node_modules +node sign_ts.mjs +``` + +Copy the three hex strings from the output into `EXPECTED_SIGNATURES` in +[test_signature_parity.py](test_signature_parity.py). + +## Test vector + +- Private key: `0xac09…ff80` (first hardhat well-known key — never use in + production) +- Signer address: `0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266` +- Chain id: `89346162` (cronos / devnet1) +- OrdersGateway: `0x5a0ac2f89e0bdeafc5c549e354842210a3e87ca5` +- Order: LIMIT IOC perp buy, 0.5 qty @ $3000, account 12345, market 1, exchange 2 +- OrderCancel: targets a specific order_id on the same account/market +- MassCancel: market_id=0 (cancel-all-markets, matching the TS SDK's + `params.marketId ?? 0` fallback in `massCancelMEOrders`) diff --git a/tests/parity/package-lock.json b/tests/parity/package-lock.json new file mode 100644 index 00000000..ffe0de76 --- /dev/null +++ b/tests/parity/package-lock.json @@ -0,0 +1,119 @@ +{ + "name": "reya-sdk-parity", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "reya-sdk-parity", + "dependencies": { + "ethers": "^6.13.0" + } + }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz", + "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==", + "license": "MIT" + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, + "node_modules/ethers": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz", + "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.10.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.17.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/tests/parity/package.json b/tests/parity/package.json new file mode 100644 index 00000000..a67c4487 --- /dev/null +++ b/tests/parity/package.json @@ -0,0 +1,8 @@ +{ + "name": "reya-sdk-parity", + "private": true, + "type": "module", + "dependencies": { + "ethers": "^6.13.0" + } +} diff --git a/tests/parity/sign_ts.mjs b/tests/parity/sign_ts.mjs new file mode 100644 index 00000000..875e184d --- /dev/null +++ b/tests/parity/sign_ts.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +// SPDX-License-Identifier: MIT +// +// EIP-712 signature parity harness — TS reference side. +// +// Reproduces the canonical signature bytes for three v2.3.0 envelopes (Order, +// OrderCancel, MassCancel) using ethers v6. Run from this directory: +// +// npm install +// node sign_ts.mjs +// +// Output is a JSON dict mapping {order, cancel, mass_cancel} → 0x-prefixed +// hex. The Python parity test (test_signature_parity.py) hardcodes these +// values and asserts the Python sign_* helpers produce the same bytes. +// +// The OrderDetails typed-data is the 14-field schema — postOnly is the 14th +// field, immediately after reduceOnly — mirroring the canonical on-chain +// OrderDetails typehash. The OrderCancel / MassCancel envelopes are unchanged. +// If anything drifts, regenerate by re-running this script and updating the +// expected hex in test_signature_parity.py. + +import { Wallet } from "ethers"; + +// Fixed test vector — first hardhat well-known key. Address derives to +// 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266. Never use in production. +const PRIVATE_KEY = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const SIGNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; + +const CHAIN_ID = 89346162; // cronos / devnet1 +const ORDERS_GATEWAY = "0x7Ec89E555c771D2B5939aBE5C4E4291852633D4D"; // devnet1 OG proxy + +const domain = { + name: "Reya", + version: "1", + verifyingContract: ORDERS_GATEWAY, + // chainId is intentionally absent — verifyingChainId travels in the envelope. +}; + +// === Order (on-chain-verified) === +const orderTypes = { + Order: [ + { name: "verifyingChainId", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "order", type: "OrderDetails" }, + ], + OrderDetails: [ + { name: "accountId", type: "uint128" }, + { name: "marketId", type: "uint128" }, + { name: "exchangeId", type: "uint128" }, + { name: "orderType", type: "uint8" }, + { name: "quantity", type: "int256" }, + { name: "limitPrice", type: "uint256" }, + { name: "triggerPrice", type: "uint256" }, + { name: "timeInForce", type: "uint8" }, + { name: "clientOrderId", type: "uint64" }, + { name: "reduceOnly", type: "bool" }, + { name: "postOnly", type: "bool" }, + { name: "expiresAfter", type: "uint256" }, + { name: "signer", type: "address" }, + { name: "nonce", type: "uint256" }, + ], +}; + +// LIMIT IOC perp buy: 0.5 qty @ 3000 limit price. +const orderValue = { + verifyingChainId: BigInt(CHAIN_ID), + deadline: BigInt(1745000000), + order: { + accountId: 12345n, + marketId: 1n, // ETH perp + exchangeId: 2n, // Reya DEX id + orderType: 0, // LIMIT + quantity: BigInt("500000000000000000"), // +0.5 E18 (signed; positive = buy) + limitPrice: BigInt("3000000000000000000000"), // 3000 E18 + triggerPrice: 0n, + timeInForce: 1, // IOC + clientOrderId: 42n, + reduceOnly: false, + postOnly: false, + expiresAfter: 0n, + signer: SIGNER_ADDRESS, + nonce: BigInt(1700000000000000), + }, +}; + +// LIMIT IOC perp SELL: identical to orderValue except the quantity sign. This +// vector exists specifically to pin the is_buy=False → negative-quantity path, +// which the buy vector above can't catch (a sign bug would still match a +// positive expected value). Only `quantity` differs, so any drift here is +// unambiguously the sign encoding. +const orderSellValue = { + ...orderValue, + order: { + ...orderValue.order, + quantity: BigInt("-500000000000000000"), // -0.5 E18 (signed; negative = sell) + }, +}; + +// Identical to orderValue except postOnly=true. Pins the maker-only flag's +// signed path — the 14th OrderDetails field. Only `postOnly` differs, so any +// drift here unambiguously isolates the postOnly encoding. +const orderPostOnlyValue = { + ...orderValue, + order: { + ...orderValue.order, + postOnly: true, + }, +}; + +// === OrderCancel (matching-engine layer) === +const orderCancelTypes = { + OrderCancel: [ + { name: "verifyingChainId", type: "uint64" }, + { name: "deadline", type: "uint64" }, + { name: "cancel", type: "OrderCancelDetails" }, + ], + OrderCancelDetails: [ + { name: "accountId", type: "uint64" }, + { name: "marketId", type: "uint64" }, + { name: "orderId", type: "uint64" }, + { name: "clOrdId", type: "uint64" }, + { name: "nonce", type: "uint64" }, + ], +}; + +const orderCancelValue = { + verifyingChainId: BigInt(CHAIN_ID), + deadline: BigInt(1745000060), + cancel: { + accountId: 12345n, + marketId: 1n, + orderId: BigInt("63552420354981888"), + clOrdId: 0n, + nonce: BigInt(1700000000000001), + }, +}; + +// === MassCancel (matching-engine layer) === +const massCancelTypes = { + MassCancel: [ + { name: "verifyingChainId", type: "uint64" }, + { name: "deadline", type: "uint64" }, + { name: "massCancel", type: "MassCancelDetails" }, + ], + MassCancelDetails: [ + { name: "accountId", type: "uint64" }, + { name: "marketId", type: "uint64" }, + { name: "nonce", type: "uint64" }, + ], +}; + +const massCancelValue = { + verifyingChainId: BigInt(CHAIN_ID), + deadline: BigInt(1745000120), + massCancel: { + accountId: 12345n, + marketId: 0n, // 0 = all markets (matches TS SDK ?? 0 fallback) + nonce: BigInt(1700000000000002), + }, +}; + +const wallet = new Wallet(PRIVATE_KEY); + +const orderSig = await wallet.signTypedData(domain, orderTypes, orderValue); +const orderSellSig = await wallet.signTypedData( + domain, + orderTypes, + orderSellValue, +); +const orderPostOnlySig = await wallet.signTypedData( + domain, + orderTypes, + orderPostOnlyValue, +); +const cancelSig = await wallet.signTypedData( + domain, + orderCancelTypes, + orderCancelValue, +); +const massCancelSig = await wallet.signTypedData( + domain, + massCancelTypes, + massCancelValue, +); + +console.log( + JSON.stringify( + { + signer_address: SIGNER_ADDRESS, + chain_id: CHAIN_ID, + orders_gateway: ORDERS_GATEWAY, + signatures: { + order: orderSig, + order_sell: orderSellSig, + order_post_only: orderPostOnlySig, + order_cancel: cancelSig, + mass_cancel: massCancelSig, + }, + }, + null, + 2, + ), +); diff --git a/tests/parity/test_order_details_golden.py b/tests/parity/test_order_details_golden.py new file mode 100644 index 00000000..985e6f21 --- /dev/null +++ b/tests/parity/test_order_details_golden.py @@ -0,0 +1,68 @@ +"""Offline parity test: the SDK's EIP-712 ``OrderDetails`` struct hash must match +the on-chain canonical digest byte-for-byte. + +The golden value is pinned from the on-chain contract's canonical OrderDetails +test vector. We re-derive the struct hash here from the SDK's own +``_ORDER_DETAILS_TYPE`` definition (the single source of truth that ``sign_order`` +uses), so any drift in field order / types / the ``postOnly`` insertion point is +caught without a devnet round-trip. + +This is the cross-implementation check for the 14-field OrderDetails (``postOnly`` +inserted after ``reduceOnly``). +""" + +from eth_abi import encode +from eth_utils import keccak + +from sdk.reya_rest_api.auth.signatures import _ORDER_DETAILS_TYPE + +# Canonical on-chain OrderDetails struct hash for the reference order below. +GOLDEN_DIGEST = "0xafd76928ba06e123f0d14a403d91fdc8a4f653c55bae7282db60b5f0acdde258" + +# Canonical reference-order field values, keyed by name so they bind to the SDK +# field order rather than a fragile positional list. +_CANONICAL_ORDER = { + "accountId": 1, + "marketId": 2, + "exchangeId": 3, + "orderType": 0, # LIMIT + "quantity": 2 * 10**18, # 2e18, signed positive + "limitPrice": 1000 * 10**18, + "triggerPrice": 0, + "timeInForce": 0, # GTC + "clientOrderId": 77, + "reduceOnly": False, + "postOnly": False, + "expiresAfter": 0, # perpetual + "signer": "0x000000000000000000000000000000000000bEEF", # address(0xBEEF) + "nonce": 100, +} + + +def _order_details_typehash(members: list[dict[str, str]]) -> bytes: + inner = ",".join(f"{m['type']} {m['name']}" for m in members) + return keccak(text=f"OrderDetails({inner})") + + +def _hash_order_details(members: list[dict[str, str]], order: dict) -> bytes: + """Mirror the on-chain ``keccak256(abi.encode(typehash, ...fields))``.""" + abi_types = ["bytes32"] + [m["type"] for m in members] + abi_values = [_order_details_typehash(members)] + [order[m["name"]] for m in members] + return keccak(encode(abi_types, abi_values)) + + +def test_order_details_struct_hash_matches_on_chain_golden_vector() -> None: + digest = "0x" + _hash_order_details(_ORDER_DETAILS_TYPE, _CANONICAL_ORDER).hex() + assert digest.lower() == GOLDEN_DIGEST.lower(), ( + f"SDK OrderDetails struct hash {digest} != on-chain golden {GOLDEN_DIGEST}. " + "The SDK EIP-712 OrderDetails definition has drifted from the on-chain " + "OrderDetails typehash (field order / types / postOnly slot)." + ) + + +def test_order_details_has_post_only_after_reduce_only() -> None: + names = [m["name"] for m in _ORDER_DETAILS_TYPE] + assert ( + names.index("postOnly") == names.index("reduceOnly") + 1 + ), "postOnly must sit immediately after reduceOnly to match the on-chain typehash" + assert len(_ORDER_DETAILS_TYPE) == 14, "OrderDetails must be the 14-field struct" diff --git a/tests/parity/test_signature_parity.py b/tests/parity/test_signature_parity.py new file mode 100644 index 00000000..5e3e93c6 --- /dev/null +++ b/tests/parity/test_signature_parity.py @@ -0,0 +1,203 @@ +# pylint: disable=redefined-outer-name +""" +TS↔Py signature parity test. + +Pinned vector: hardhat test private key 0xac09…ff80 (signer 0xf39F…2266) signing +the Order envelope (14-field OrderDetails incl. postOnly) plus the OrderCancel +and MassCancel envelopes, against the testnet OrdersGateway at chain id 89346162. + +Expected hex was produced by ``node tests/parity/sign_ts.mjs``, which uses +ethers v6's ``signTypedData`` against the same orderTypes / orderCancelTypes / +massCancelTypes as the canonical off-chain signer. + +If this test ever fails, either: +- The Python ``sign_*`` helpers diverged from the canonical TS impl, or +- The TS impl evolved and you need to re-run ``node sign_ts.mjs`` and update + the EXPECTED_SIGNATURES dict below. +""" + +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from sdk.reya_rest_api.auth.signatures import OrderTypeInt, SignatureGenerator, TimeInForceInt +from sdk.reya_rest_api.config import TradingConfig + +# === Fixed test vector === +PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +SIGNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +CHAIN_ID = 89346162 # cronos / devnet1 +ORDERS_GATEWAY = "0x7Ec89E555c771D2B5939aBE5C4E4291852633D4D" + +# Hex produced by tests/parity/sign_ts.mjs against the canonical TS sign impl +# (ethers v6 signTypedData with the orderTypes from the off-chain monorepo). +EXPECTED_SIGNATURES = { + "order": ( + "0x8b7d36f622ad44815d66a6f75678f40c99cdb965088bcc857b53db5f8a7b272d" + "0204574e11bb9c8109c132490b7f36ff4dc6ee1e93c277a616cf60c2db26cce6" + "1c" + ), + # Same envelope as "order" but with a SELL (negative quantity) — pins the + # is_buy=False sign-encoding path that the buy vector can't catch. + "order_sell": ( + "0xf2d69b90a93c361c28a44483d26e6667721f63fb8c8ec1a12106773c663a4397" + "2c42d4b2ea68531b9b63d25e3a90cf093892db8983525424a6b0044d4c6d28bf" + "1b" + ), + # Same envelope as "order" but with postOnly=true — pins the 14th + # OrderDetails field; only postOnly differs from "order". + "order_post_only": ( + "0x752574d6c57c9b9736966f7ec318c9a5ee9dc0f1e7ea631b5056061c0241d0bf" + "1cd0cc3b3bd3bbee542c7a95f2bc1ebd85afb25152f4b648f57989063745af9c" + "1c" + ), + "order_cancel": ( + "0x90ddba6ff879dee4773c214c927a470720f42378574281866edce100ea8c59d7" + "75fb29e4ab6108a9ea84bfe12fffcdbbd6dfff98ea6ae034bbd87f4c21254f94" + "1b" + ), + "mass_cancel": ( + "0x86d4f060ffbba16698cf8f89fdeabb0397a814be6f54075f908ccbd73894a422" + "7c33b0b77ac8c2e495eca0848e56e60711cd5fa60b657a4ba675fd6bd13be920" + "1b" + ), +} + + +@pytest.fixture(scope="module") +def signer() -> SignatureGenerator: + """SignatureGenerator with the pinned test key + chain id.""" + config = TradingConfig( + api_url="https://invalid.example", # not used for signing + chain_id=CHAIN_ID, + owner_wallet_address=SIGNER_ADDRESS, + private_key=PRIVATE_KEY, + account_id=12345, + ) + # Sanity: the test relies on the testnet OG address, which TradingConfig + # derives from chain_id. If somebody flips that mapping, fail loudly. + assert config.default_orders_gateway_address == ORDERS_GATEWAY, ( + f"OrdersGateway address mismatch: config returned " + f"{config.default_orders_gateway_address}, parity vector expects {ORDERS_GATEWAY}" + ) + return SignatureGenerator(config) + + +def test_signer_address_matches_test_vector(signer: SignatureGenerator) -> None: + """Sanity: the configured private key derives to the address the TS vector signed with.""" + assert signer.signer_wallet_address.lower() == SIGNER_ADDRESS.lower() + + +def test_order_signature_parity(signer: SignatureGenerator) -> None: + """Python sign_order produces the same bytes as ethers v6 signTypedData.""" + 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.IOC), + client_order_id=42, + reduce_only=False, + expires_after=0, + nonce=1700000000000000, + deadline=1745000000, + ) + assert ( + sig == EXPECTED_SIGNATURES["order"] + ), f"Order signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['order']}" + + +def test_order_sell_signature_parity(signer: SignatureGenerator) -> None: + """is_buy=False must encode a negative quantity identically to ethers v6. + + Identical to the buy vector except ``is_buy=False``; the only signed field + that changes is the order quantity's sign, so any drift isolates the + is_buy → signed-quantity encoding. + """ + sig = signer.sign_order( + account_id=12345, + market_id=1, + exchange_id=2, + order_type=int(OrderTypeInt.LIMIT), + is_buy=False, + 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=0, + nonce=1700000000000000, + deadline=1745000000, + ) + assert ( + sig == EXPECTED_SIGNATURES["order_sell"] + ), f"Sell-order signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['order_sell']}" + + +def test_order_post_only_signature_parity(signer: SignatureGenerator) -> None: + """post_only=True must encode the 14th OrderDetails field identically to ethers v6. + + Identical to the buy vector except ``post_only=True``; the only signed field + that changes is ``OrderDetails.postOnly``, so any drift isolates the postOnly + encoding. This is an encoding-level parity check — the IOC + post_only combo + is rejected at the client layer (``build_create_limit_order_payload``), but + ``sign_order`` itself signs whatever field values it is handed. + """ + 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.IOC), + client_order_id=42, + reduce_only=False, + expires_after=0, + nonce=1700000000000000, + deadline=1745000000, + post_only=True, + ) + assert ( + sig == EXPECTED_SIGNATURES["order_post_only"] + ), f"post_only-order signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['order_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( + account_id=12345, + market_id=1, + order_id=63552420354981888, + client_order_id=0, + nonce=1700000000000001, + deadline=1745000060, + ) + assert ( + sig == EXPECTED_SIGNATURES["order_cancel"] + ), f"OrderCancel signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['order_cancel']}" + + +def test_mass_cancel_signature_parity(signer: SignatureGenerator) -> None: + """Python sign_mass_cancel produces the same bytes as ethers v6 signTypedData. + + market_id=0 corresponds to ``cancel across all markets`` (matches the TS + SDK ``params.marketId ?? 0`` fallback in ``massCancelMEOrders``).""" + sig = signer.sign_mass_cancel( + account_id=12345, + market_id=0, + nonce=1700000000000002, + deadline=1745000120, + ) + assert ( + sig == EXPECTED_SIGNATURES["mass_cancel"] + ), f"MassCancel signature drift:\n py: {sig}\n ts: {EXPECTED_SIGNATURES['mass_cancel']}" diff --git a/tests/parity/test_wire_serialization.py b/tests/parity/test_wire_serialization.py new file mode 100644 index 00000000..a65ef3c9 --- /dev/null +++ b/tests/parity/test_wire_serialization.py @@ -0,0 +1,241 @@ +# pylint: disable=protected-access,redefined-outer-name +"""Wire-serialization guards for the order-payload builders. + +Offline (no devnet): builds payloads with a fixed key + a hand-seeded +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). + +Regression: the sell-trigger sentinel limit price is ``Decimal("0.000000001")``, +and ``str(Decimal("0.000000001"))`` is ``"1E-9"``. The server's ethers +``FixedNumber`` parser rejects ``"1E-9"`` with INVALID_ARGUMENT, so a TP/SL +sell with no explicit limit price failed on the wire even though the signature +was correct. The builder now uses ``format(value, "f")``. +""" + +from __future__ import annotations + +import time + +import pytest + +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 + +PRIVATE_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" +SIGNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +CHAIN_ID = 89346162 +PERP_SYMBOL = "ETHRUSDPERP" +SPOT_SYMBOL = "WETHRUSD" # market_id >= _SPOT_MARKET_ID_OFFSET => spot namespace + + +@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) so the builders are + exercised without any devnet dependency. + """ + 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, + ) + c = ReyaTradingClient(config) + c._symbol_to_market_id = {PERP_SYMBOL: 1, SPOT_SYMBOL: _SPOT_MARKET_ID_OFFSET + 1} + c._symbol_to_tick_size = {PERP_SYMBOL: "0.001"} # tick size drives the sell-trigger sentinel + c._initialized = True + return c + + +def test_sell_trigger_sentinel_is_one_tick(client: ReyaTradingClient) -> None: + """is_buy=False + no limit_px → sentinel is exactly one tick (spacing-conforming), + not a sub-tick value the matching engine would reject as off-grid.""" + payload, _ = client.build_create_trigger_order_payload( + TriggerOrderParameters( + symbol=PERP_SYMBOL, + is_buy=False, + qty="0.01", + trigger_px="1", + trigger_type=OrderType.STOP_LOSS, + ) + ) + assert payload["limitPx"] == "0.001" # == market tick size + assert "E" not in payload["limitPx"].upper(), f"limitPx in scientific notation: {payload['limitPx']!r}" + + +def test_buy_trigger_sentinel_limit_px_is_plain_decimal(client: ReyaTradingClient) -> None: + """is_buy=True + no limit_px → huge sentinel must also be plain (no 'E').""" + payload, _ = client.build_create_trigger_order_payload( + TriggerOrderParameters( + symbol=PERP_SYMBOL, + is_buy=True, + qty="0.01", + trigger_px="1000000", + trigger_type=OrderType.TAKE_PROFIT, + ) + ) + assert payload["limitPx"] == "100000000000000000000" + assert "E" not in payload["limitPx"].upper() + + +def test_caller_supplied_small_limit_px_is_plain_decimal(client: ReyaTradingClient) -> None: + """A small explicit limit_px must serialize as a plain decimal, never sci + notation: str(Decimal("0.0000001")) == "1E-7", which the server's ethers + FixedNumber parser rejects — this is exactly what format(..., "f") guards.""" + payload, _ = client.build_create_trigger_order_payload( + TriggerOrderParameters( + symbol=PERP_SYMBOL, + is_buy=False, + qty="0.01", + trigger_px="1", + trigger_type=OrderType.STOP_LOSS, + limit_px="0.0000001", + ) + ) + assert payload["limitPx"] == "0.0000001" + assert "E" not in payload["limitPx"].upper(), f"limitPx in scientific notation: {payload['limitPx']!r}" + + +def test_limit_payload_decouples_deadline_from_expires_after(client: ReyaTradingClient) -> None: + """GTC limit: ``expiresAfter`` is 0 / perpetual and ``deadline`` is a short, + independent unix-seconds window — not the old far-future + ``deadline == expiresAfter`` lifetime stopgap.""" + before = int(time.time()) + 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, + ) + ) + # Perpetual lifetime — rests until filled or cancelled. + assert payload["expiresAfter"] == 0 + # A near-future unix-seconds deadline, decoupled from (and not equal to) expiresAfter. + assert before <= payload["deadline"] <= before + 600 + assert payload["deadline"] != payload["expiresAfter"] + + +def test_limit_payload_post_only_defaults_false(client: ReyaTradingClient) -> None: + """postOnly is signed into the 14-field digest and carried on the wire as + False by default (no caller opt-in).""" + 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, + ) + ) + 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"): + 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, + ) + ) + + +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.IOC, + post_only=True, + ) + ) + + +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"): + client.build_create_limit_order_payload( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=True, + limit_px="3000", + qty="0.01", + time_in_force=TimeInForce.GTT, + ) + ) + + +def test_reduce_only_on_spot_rejected(client: ReyaTradingClient) -> None: + """reduce_only is perp-IOC-only; on a spot order it must be rejected at entry + (the server forbids it on spot), not silently dropped.""" + with pytest.raises(ValueError, match="reduce_only is only supported on perp IOC"): + client.build_create_limit_order_payload( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px="3000", + qty="0.01", + time_in_force=TimeInForce.IOC, + reduce_only=True, + ) + ) + + +def test_reduce_only_on_trigger_rejected(client: ReyaTradingClient) -> None: + """reduce_only / close-on-trigger TP/SL isn't supported yet — an explicit + reduce_only on a trigger order is rejected rather than signed + sent.""" + with pytest.raises(ValueError, match="reduce_only on TP/SL trigger orders is not supported"): + client.build_create_trigger_order_payload( + TriggerOrderParameters( + symbol=PERP_SYMBOL, + is_buy=False, + qty="0.01", + trigger_px="1000", + trigger_type=OrderType.STOP_LOSS, + reduce_only=True, + ) + ) + + +def test_cancel_requires_an_identifier(client: ReyaTradingClient) -> None: + """A cancel must carry at least one of order_id / client_order_id; neither is rejected.""" + with pytest.raises(ValueError, match="Provide either order_id or client_order_id"): + client.build_cancel_order_payload(symbol=PERP_SYMBOL) + + +def test_cancel_accepts_both_identifiers(client: ReyaTradingClient) -> None: + """The client accepts both order_id and client_order_id and carries both on the + wire (the server resolves precedence in favour of order_id) rather than rejecting + the combination.""" + payload = client.build_cancel_order_payload( + symbol=PERP_SYMBOL, + order_id="123", + client_order_id=456, + ) + assert payload["orderId"] == "123" + assert payload["clientOrderId"] == 456 diff --git a/tests/test_orderbook/__init__.py b/tests/test_orderbook/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_orderbook/conftest.py b/tests/test_orderbook/conftest.py new file mode 100644 index 00000000..dfca9b6a --- /dev/null +++ b/tests/test_orderbook/conftest.py @@ -0,0 +1,221 @@ +""" +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_execution_busts.py b/tests/test_orderbook/test_execution_busts.py new file mode 100644 index 00000000..55b06e2e --- /dev/null +++ b/tests/test_orderbook/test_execution_busts.py @@ -0,0 +1,75 @@ +""" +Smoke tests for the unified ``executionBusts`` endpoints (REST + WS). + +Under v2.3.0 spot and perp share one bust surface. These tests exercise the +endpoint shape and the WebSocket subscription handshake — they don't deliberately +trigger busts (those scenarios were spot-specific in the AMM era and need to be +re-derived for the OB matching engine; tracked as follow-up). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from sdk.open_api.models.execution_bust import ExecutionBust +from sdk.open_api.models.execution_bust_list import ExecutionBustList +from tests.helpers import ReyaTester + + +@pytest.mark.asyncio +async def test_wallet_execution_busts_endpoint_shape(reya_tester: ReyaTester) -> None: + """``GET /v2/wallet/{address}/executionBusts`` returns a well-formed list.""" + bust_list = await reya_tester.client.get_execution_busts() + + assert isinstance(bust_list, ExecutionBustList) + assert isinstance(bust_list.data, list) + for bust in bust_list.data: + assert isinstance(bust, ExecutionBust) + assert bust.symbol, "bust.symbol must be populated" + assert bust.account_id is not None + assert bust.exchange_id is not None + + +@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") + + 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 + + +@pytest.mark.asyncio +async def test_wallet_execution_busts_websocket_subscribes(reya_tester: ReyaTester) -> None: + """The wallet ``executionBusts`` WS subscription is established on session start. + + ``ReyaTester.setup`` already subscribes via ``ws.wallet.execution_busts(addr).subscribe()``; + this test just verifies the channel is in the active set without forcing a bust event. + """ + websocket = reya_tester.websocket + if websocket is None: + pytest.skip("WebSocket not connected") + assert websocket is not None # narrow after the skip above + + expected_channel = f"/v2/wallet/{reya_tester.owner_wallet_address}/executionBusts" + + # Give the subscribe message a brief moment to register if a fresh ws is in flight. + for _ in range(5): + if expected_channel in websocket.active_subscriptions: + return + await asyncio.sleep(0.1) + + assert expected_channel in websocket.active_subscriptions, ( + f"Expected {expected_channel} in active subscriptions, got " f"{sorted(websocket.active_subscriptions)}" + ) diff --git a/tests/test_orderbook/test_ioc_orders.py b/tests/test_orderbook/test_ioc_orders.py new file mode 100644 index 00000000..93803804 --- /dev/null +++ b/tests/test_orderbook/test_ioc_orders.py @@ -0,0 +1,154 @@ +""" +IOC (Immediate-Or-Cancel) order tests parametrized over [spot, perp]. + +IOC orders execute immediately against available liquidity and the unfilled +portion is cancelled. Under v2.3.0 the matching-engine semantics are identical +for both market types. + +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``. +""" + +from __future__ import annotations + +import asyncio +from decimal import Decimal + +import pytest + +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_ioc_full_fill_against_resting_maker( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """Maker rests a GTC, taker IOC at the crossing price → full fill, no resting taker.""" + 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 — IOC fill could match externally instead of our maker") + + 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, + ) + taker_order_id = await taker.orders.create_limit(taker_params) + assert taker_order_id is not None + + # Allow the ME to process and the maker to flip out of OPEN state. + await asyncio.sleep(0.5) + 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 maker_order_id not in open_ids, f"[{market_type}] maker should be filled" + assert taker_order_id not in open_ids, f"[{market_type}] IOC taker should not rest" + + +@pytest.mark.asyncio +async def test_ioc_no_liquidity_unfills_immediately( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + taker: ReyaTester, +) -> None: + """An IOC priced where it can't match → cancelled immediately without resting.""" + await market_config.refresh_order_book(taker.data) + await taker.orders.close_all(fail_if_none=False) + + # If external bids exist at our extreme sell price, the test would match — skip. + if market_config.has_usable_bid_liquidity: + pytest.skip("external bid liquidity present — IOC sell at extreme price would match") + + safe_no_match_px = str(market_config.get_safe_no_match_sell_price()) + + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=False, + limit_px=safe_no_match_px, + qty=market_config.min_qty, + time_in_force=TimeInForce.IOC, + ) + order_id = await taker.orders.create_limit(params) + assert order_id is not None + + await asyncio.sleep(0.5) + open_orders = await taker.client.get_open_orders() + open_ids = {o.order_id for o in open_orders if o.symbol == market_config.symbol} + assert order_id not in open_ids, f"[{market_type}] unfilled IOC must not rest" + + +@pytest.mark.asyncio +async def test_ioc_partial_fill_when_maker_smaller( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """IOC for 2x maker qty against a single maker level → partial fill, IOC remainder dropped.""" + 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 — partial-fill assertion requires controlled book") + + cross_px = str(market_config.price(0.99)) + maker_qty = market_config.min_qty + taker_qty = str(Decimal(maker_qty) * 2) + + maker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=cross_px, + qty=maker_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=taker_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) + + # Maker fully consumed; taker did not rest (IOC drops the remainder). + 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 maker_order_id not in open_ids, f"[{market_type}] maker should be filled" + + 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" diff --git a/tests/test_orderbook/test_limit_orders.py b/tests/test_orderbook/test_limit_orders.py new file mode 100644 index 00000000..1fa07462 --- /dev/null +++ b/tests/test_orderbook/test_limit_orders.py @@ -0,0 +1,85 @@ +""" +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_orderbook/test_maker_taker_matching.py b/tests/test_orderbook/test_maker_taker_matching.py new file mode 100644 index 00000000..5524f778 --- /dev/null +++ b/tests/test_orderbook/test_maker_taker_matching.py @@ -0,0 +1,118 @@ +""" +Maker/taker matching end-to-end tests parametrized over [spot, perp]. + +This is the matching-engine smoke for both market types: maker rests a GTC, +taker sends an IOC at a crossing price, both observe the fill via REST and +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. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from sdk.open_api.models.depth import Depth +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.reya_tester import logger +from tests.test_orderbook.conftest import PerpTestConfig +from tests.test_spot.spot_config import SpotTestConfig + + +@pytest.mark.asyncio +async def test_maker_taker_match_e2e( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """Maker GTC + taker IOC at the same price → maker filled, taker IOC consumed. + + Verifies the match end-to-end: + + 1. Maker order rests on the book and shows up in REST depth. + 2. Taker IOC at maker's price executes immediately. + 3. Maker order moves out of OPEN. + 4. Order-changes event lands on the maker's wallet WS channel. + 5. Symbol-level execution event lands on the taker's wallet WS channel. + """ + await market_config.refresh_order_book(maker.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 — match could go to an external counterparty") + + cross_px_float = market_config.price(0.99) + cross_px = str(cross_px_float) + + # Step 1: Maker places GTC buy. + 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) + + # Step 2: Maker order is visible in L2 depth. + await asyncio.sleep(0.1) + depth = await maker.data.market_depth(market_config.symbol) + assert isinstance(depth, Depth), f"[{market_type}] expected Depth" + found_in_depth = any(abs(float(bid.px) - cross_px_float) < 0.01 for bid in (depth.bids or [])) + assert found_in_depth, f"[{market_type}] maker order should appear at ${cross_px_float} in L2 depth" + logger.info(f"[{market_type}] ✅ maker visible in L2 at ${cross_px_float}") + + # Step 3: Taker IOC sell at the same price. + taker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=False, + limit_px=cross_px, + qty=market_config.min_qty, + time_in_force=TimeInForce.IOC, + ) + taker_order_id = await taker.orders.create_limit(taker_params) + assert taker_order_id is not None + + # Step 4: Maker order moves out of OPEN (FILLED via the match). + try: + await maker.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=10) + logger.info(f"[{market_type}] ✅ maker FILLED") + except RuntimeError: + # Some environments emit only PARTIALLY_FILLED then close — accept any non-OPEN final state. + 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 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. + 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]}" + ) + + # Step 6: WS execution event for taker — perp goes via perp_executions, spot via spot_executions. + if market_type == "spot": + assert taker.ws.last_spot_execution is not None, "[spot] expected last_spot_execution on taker WS" + assert taker.ws.last_spot_execution.symbol == market_config.symbol + else: + # Perp: confirm at least one perp execution lands within a brief polling window. + for _ in range(20): + if taker.ws.perp_executions.find_last(lambda e: e.symbol == market_config.symbol) is not None: + break + await asyncio.sleep(0.25) + last_perp = taker.ws.perp_executions.find_last(lambda e: e.symbol == market_config.symbol) + assert last_perp is not None, "[perp] expected perp execution on taker WS" + + # Cleanup + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) diff --git a/tests/test_orderbook/test_order_cancellation.py b/tests/test_orderbook/test_order_cancellation.py new file mode 100644 index 00000000..c6d9c328 --- /dev/null +++ b/tests/test_orderbook/test_order_cancellation.py @@ -0,0 +1,155 @@ +""" +Order cancellation tests parametrized over [spot, perp]. + +Cancellation behaviour is identical for both market types under v2.3.0 — the +matching engine resolves the order to its book, applies the EIP-712-signed +``OrderCancel`` envelope, and acks. These tests exercise: + +- Single-order cancel by ``order_id``. +- Mass cancel of all open orders on a symbol. +- Re-cancel of an already-cancelled order (idempotent / explicit error). +""" + +from __future__ import annotations + +import asyncio + +import pytest + +from sdk.open_api.exceptions import ApiException +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 + + +def _safe_resting_price(market_config: SpotTestConfig | PerpTestConfig) -> str: + """Price far below oracle so a buy GTC will rest without crossing.""" + return str(round(market_config.oracle_price * 0.5, 2)) + + +@pytest.mark.asyncio +async def test_cancel_single_open_order( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """Place a far-from-market GTC, cancel by ``order_id``, observe CANCELLED state.""" + 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, + ) + 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) + + await maker.client.cancel_order( + symbol=market_config.symbol, + account_id=maker.account_id, + order_id=order_id, + ) + + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED) + await maker.check.no_open_orders() + + +@pytest.mark.asyncio +async def test_mass_cancel_clears_multiple_orders( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """Place several orders at distinct prices, mass-cancel, observe all CANCELLED.""" + placed: list[str] = [] + for offset in range(3): + # Spread within the circuit-breaker band but stay far enough not to fill. + px = str(round(market_config.oracle_price * (0.5 - 0.001 * offset), 2)) + 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 at offset={offset}" + placed.append(order_id) + + await maker.client.mass_cancel( + symbol=market_config.symbol, + account_id=maker.account_id, + ) + + # Allow the matching engine a moment to apply the mass-cancel before polling. + await asyncio.sleep(0.5) + for order_id in placed: + 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, + market_type: str, + maker: ReyaTester, +) -> None: + """Cancelling an order_id that was never placed should raise — protects against typos / replay.""" + bogus_order_id = "9999999999999999999" + + with pytest.raises(ApiException) as exc_info: + await maker.client.cancel_order( + symbol=market_config.symbol, + account_id=maker.account_id, + order_id=bogus_order_id, + ) + + err = str(exc_info.value).lower() + assert ( + "missing" in err or "not found" in err or "400" in err or "404" in err + ), f"[{market_type}] expected unknown-order rejection, got: {exc_info.value}" + + +@pytest.mark.asyncio +async def test_cancel_already_cancelled_rejects( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """A second cancel for the same order_id should raise — the order is gone from the book.""" + 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, + ) + order_id = await maker.orders.create_limit(params) + assert order_id is not None + await maker.wait.for_order_creation(order_id) + + # First cancel — succeeds. + await maker.client.cancel_order( + symbol=market_config.symbol, + account_id=maker.account_id, + order_id=order_id, + ) + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED) + + # Second cancel — API rejects: order no longer open. + with pytest.raises(ApiException) as exc_info: + await maker.client.cancel_order( + symbol=market_config.symbol, + account_id=maker.account_id, + order_id=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 cancel rejection, got: {exc_info.value}" diff --git a/tests/test_orderbook/test_self_match_prevention.py b/tests/test_orderbook/test_self_match_prevention.py new file mode 100644 index 00000000..6a8bc670 --- /dev/null +++ b/tests/test_orderbook/test_self_match_prevention.py @@ -0,0 +1,183 @@ +""" +Self-match prevention tests parametrized over [spot, perp]. + +The matching engine prevents an account from matching against itself: when an +incoming taker would cross with a resting order from the same account, the +TAKER is cancelled and the MAKER stays on the book. This holds regardless of +market type because both spot and perp share the same matching engine under +v2.3.0. + +These tests need a controlled environment — if external liquidity exists at +the test price, the taker would match externally instead of triggering the +self-match path. We refresh the depth and skip when external liquidity is +present at crossing prices. +""" + +from __future__ import annotations + +import asyncio + +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.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: + """Skip if external liquidity is on the book — would interfere with self-match assertions.""" + await market_config.refresh_order_book(tester.data) + if market_config.has_any_external_liquidity: + pytest.skip("external liquidity present — self-match tests need a controlled book") + + +@pytest.mark.asyncio +async def test_self_match_gtc_taker_sell_cancelled( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """GTC buy + GTC sell crossing from SAME account → taker cancelled, maker stays.""" + await _skip_if_external_liquidity(market_config, maker) + await maker.orders.close_all(fail_if_none=False) + + cross_price = str(market_config.price(0.97)) + + # Maker buy at 97% of oracle. + maker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=cross_price, + 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 sell at the same price (crossing, same account → self-match). + taker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=False, + limit_px=cross_price, + qty=market_config.min_qty, + time_in_force=TimeInForce.GTC, + ) + taker_order_id = await maker.orders.create_limit(taker_params) + assert taker_order_id is not None + + # Allow ME to process. + await asyncio.sleep(0.3) + + 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 should be CANCELLED on self-match" + assert maker_order_id in open_ids, f"[{market_type}] maker should remain OPEN" + logger.info(f"[{market_type}] ✅ self-match: taker cancelled, maker preserved") + + # Cleanup + await maker.client.cancel_order( + order_id=maker_order_id, + symbol=market_config.symbol, + account_id=maker.account_id, + ) + await maker.wait.for_order_state(maker_order_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_self_match_ioc_taker_buy_cancelled( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """GTC sell maker + IOC buy taker (same account, crossing) → IOC cancelled with no fill.""" + await _skip_if_external_liquidity(market_config, maker) + await maker.orders.close_all(fail_if_none=False) + + cross_price = str(market_config.price(1.03)) + + maker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=False, + limit_px=cross_price, + 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=True, + limit_px=cross_price, + qty=market_config.min_qty, + time_in_force=TimeInForce.IOC, + ) + taker_order_id = await maker.orders.create_limit(taker_params) + assert taker_order_id is not None + + await asyncio.sleep(0.3) + + open_orders = await maker.client.get_open_orders() + open_ids = {o.order_id for o in open_orders if o.symbol == market_config.symbol} + + # IOC taker is gone (either cancelled by self-match or naturally not resting). Maker stays. + assert taker_order_id not in open_ids, f"[{market_type}] IOC taker should not rest" + assert maker_order_id in open_ids, f"[{market_type}] GTC maker should remain after self-match block" + + await maker.client.cancel_order( + order_id=maker_order_id, + symbol=market_config.symbol, + account_id=maker.account_id, + ) + + +@pytest.mark.asyncio +async def test_cross_account_match_fills_normally( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, + taker: ReyaTester, +) -> None: + """Sanity: when maker and taker are different accounts, the match goes through.""" + await _skip_if_external_liquidity(market_config, maker) + await maker.orders.close_all(fail_if_none=False) + await taker.orders.close_all(fail_if_none=False) + + cross_price = str(market_config.price(0.99)) + + maker_params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=cross_price, + 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_price, + qty=market_config.min_qty, + time_in_force=TimeInForce.IOC, + ) + taker_order_id = await taker.orders.create_limit(taker_params) + assert taker_order_id is not None + + # Maker order should be FILLED (or at minimum gone from open orders). + try: + await maker.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=10) + except RuntimeError: + # Allow PARTIALLY_FILLED tolerant path + 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 maker_order_id not in open_ids, f"[{market_type}] cross-account match should consume the maker" diff --git a/tests/test_orderbook/test_websocket_events.py b/tests/test_orderbook/test_websocket_events.py new file mode 100644 index 00000000..a6ecd9fa --- /dev/null +++ b/tests/test_orderbook/test_websocket_events.py @@ -0,0 +1,124 @@ +""" +WebSocket event verification tests parametrized over [spot, perp]. + +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 +match (positions accrue instead). +""" + +from __future__ import annotations + +import asyncio + +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_ws_order_change_on_create( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """A new GTC order surfaces on the wallet ``orderChanges`` WS channel.""" + _ = market_type # consumed by the [spot, perp] parametrization + maker.ws.order_changes.clear() + + far_buy_px = str(round(market_config.oracle_price * 0.5, 2)) + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=far_buy_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 + await maker.wait.for_order_creation(order_id) + + maker.check.ws_order_change_received( + order_id=order_id, + expected_symbol=market_config.symbol, + expected_side="B", + expected_qty=market_config.min_qty, + ) + + # Cleanup + await maker.client.cancel_order(order_id=order_id, symbol=market_config.symbol, account_id=maker.account_id) + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED) + + +@pytest.mark.asyncio +async def test_ws_order_change_on_cancel( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """Cancelling an open order surfaces a CANCELLED orderChanges event on the WS channel.""" + far_buy_px = str(round(market_config.oracle_price * 0.5, 2)) + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=far_buy_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 + await maker.wait.for_order_creation(order_id) + + await maker.client.cancel_order(order_id=order_id, symbol=market_config.symbol, account_id=maker.account_id) + + # ``for_order_state`` waits for WS confirmation as the primary signal. + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED) + + ws_order = maker.ws.orders.get(str(order_id)) + assert ws_order is not None, f"[{market_type}] expected WS order entry for {order_id}" + status_value = ws_order.status.value if hasattr(ws_order.status, "value") else ws_order.status + assert status_value == "CANCELLED", f"[{market_type}] expected CANCELLED, got {status_value}" + + +@pytest.mark.asyncio +async def test_ws_depth_subscription_alive( + market_config: SpotTestConfig | PerpTestConfig, + market_type: str, + maker: ReyaTester, +) -> None: + """Subscribing to ``/depth`` and resting an order surfaces depth updates within a few seconds.""" + if maker.websocket is None: + pytest.skip("WebSocket not connected") + + maker.ws.subscribe_to_market_depth(market_config.symbol) + + far_buy_px = str(round(market_config.oracle_price * 0.5, 2)) + params = LimitOrderParameters( + symbol=market_config.symbol, + is_buy=True, + limit_px=far_buy_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 + + # Poll briefly for a depth event capturing our resting order. + for _ in range(20): + depth = maker.ws.depth.get(market_config.symbol) + if depth is not None and depth.bids: + break + await asyncio.sleep(0.25) + + depth = maker.ws.depth.get(market_config.symbol) + assert depth is not None, f"[{market_type}] expected depth update for {market_config.symbol}" + + # Cleanup + await maker.client.cancel_order(order_id=order_id, symbol=market_config.symbol, account_id=maker.account_id) + await maker.wait.for_order_state(order_id, OrderStatus.CANCELLED) diff --git a/tests/test_perps/test_dynamic_pricing.py b/tests/test_perps/test_dynamic_pricing.py deleted file mode 100644 index d55933b8..00000000 --- a/tests/test_perps/test_dynamic_pricing.py +++ /dev/null @@ -1,375 +0,0 @@ -""" -End-to-end tests for dynamic pricing (exponential logPriceMultiplier formula). - -Validates that the off-chain API correctly reflects on-chain dynamic pricing -state, and that execution prices are consistent with the pool price and spread. - -Tests are organized in three levels: - - Level 1: Read-only sanity checks (no trades) - - Level 2: Execution price tests (min-size IOC trades) - - Level 3: Behavioral tests (direction-only assertions) -""" - -import asyncio -import math -import os - -import pytest -from dotenv import load_dotenv - -from sdk.open_api.models.price import Price -from sdk.open_api.models.time_in_force import TimeInForce -from sdk.reya_rest_api.config import MAINNET_CHAIN_ID -from sdk.reya_rest_api.models import LimitOrderParameters -from tests.helpers import ReyaTester -from tests.helpers.market_trackers import fetch_market_trackers, fetch_price -from tests.helpers.reya_tester import limit_order_params_to_order, logger - -SYMBOL = "ETHRUSDPERP" -TRADE_QTY = "0.01" -# ETH market ID — used for the legacy v1 trackers endpoint -ETH_MARKET_ID = 1 - - -def _get_api_url() -> str: - """Resolve API URL from env — no account needed.""" - load_dotenv() - chain_id = int(os.environ.get("CHAIN_ID", MAINNET_CHAIN_ID)) - if chain_id == MAINNET_CHAIN_ID: - default = "https://api.reya.xyz/v2" - else: - default = "https://api-cronos.reya.xyz/v2" - return os.environ.get("REYA_API_URL", default) - - -# ============================================================================ -# Helpers -# ============================================================================ - - -async def _get_price_and_trackers(api_url: str): - """Fetch both price and raw market trackers in parallel via raw HTTP.""" - price_info, trackers = await asyncio.gather( - fetch_price(api_url, SYMBOL), - fetch_market_trackers(api_url, ETH_MARKET_ID), - ) - return price_info, trackers - - -async def _execute_ioc_trade(reya_tester: ReyaTester, is_buy: bool): - """Execute a minimum-size IOC trade and return the PerpExecution.""" - market_price = await reya_tester.data.current_price(SYMBOL) - # Set limit price with 10% buffer to ensure IOC fills - limit_px = str(float(market_price) * (1.1 if is_buy else 0.9)) - - order_params = LimitOrderParameters( - symbol=SYMBOL, - is_buy=is_buy, - limit_px=limit_px, - qty=TRADE_QTY, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - - await reya_tester.orders.create_limit(order_params) - expected_order = limit_order_params_to_order(order_params, reya_tester.account_id) - execution = await reya_tester.wait.for_order_execution(expected_order) - return execution - - -# ============================================================================ -# Level 1: Read-Only Sanity (no trades) -# ============================================================================ - - -class TestDynamicPricingReadOnly: - """Read-only tests that validate pricing data without executing trades.""" - - @pytest.mark.asyncio - async def test_market_tracker_fields_exist_and_valid(self): - """T1.1 — Market tracker fields exist and are valid. - - Verifies that the legacy v1 trackers endpoint returns the dynamic pricing - fields (logPriceMultiplier, priceSpread, depthFactor) with valid values. - """ - api_url = _get_api_url() - trackers = await fetch_market_trackers(api_url, ETH_MARKET_ID) - - # depth_factor must be non-negative (drives price impact scaling; 0 on unconfigured markets) - assert trackers.depth_factor >= 0, f"depth_factor should be >= 0, got {trackers.depth_factor}" - - # price_spread must be non-negative (symmetric spread around pool price) - assert trackers.price_spread >= 0, f"price_spread should be >= 0, got {trackers.price_spread}" - - # log_price_multiplier is a valid signed number (can be 0, positive, or negative) - # Just verify it's finite and within a reasonable range (< 1e18 in magnitude) - assert ( - trackers.log_price_multiplier.is_finite() - ), f"log_price_multiplier should be finite, got {trackers.log_price_multiplier}" - - logger.info( - f"✅ T1.1 passed: depth_factor={trackers.depth_factor}, " - f"price_spread={trackers.price_spread}, " - f"log_price_multiplier={trackers.log_price_multiplier}" - ) - - @pytest.mark.asyncio - async def test_pool_price_matches_formula(self): - """T1.2 — Pool price ≈ oraclePrice × exp(logPriceMultiplier). - - Verifies the fundamental relationship: poolPrice = oraclePrice * exp(logF / 1e18). - Uses ~1% tolerance to account for logF changing between API calls. - """ - api_url = _get_api_url() - price_info, trackers = await _get_price_and_trackers(api_url) - - assert price_info.pool_price is not None, "pool_price should be available for ETHRUSDPERP" - oracle_price = price_info.oracle_price - pool_price = price_info.pool_price - log_f = float(trackers.log_price_multiplier) - - # Pool price formula: oraclePrice * exp(logF / 1e18) - expected_pool_price = oracle_price * math.exp(log_f / 1e18) - - # Allow ~1% tolerance (logF can change between the two API calls) - tolerance = 0.01 - relative_error = abs(pool_price - expected_pool_price) / expected_pool_price - - assert relative_error < tolerance, ( - f"Pool price {pool_price} doesn't match formula " - f"oracle({oracle_price}) * exp(logF({log_f}) / 1e18) = {expected_pool_price}. " - f"Relative error: {relative_error:.4f} (tolerance: {tolerance})" - ) - - logger.info( - f"✅ T1.2 passed: poolPrice={pool_price:.2f}, " - f"expected={expected_pool_price:.2f}, " - f"relError={relative_error:.6f}" - ) - - @pytest.mark.asyncio - async def test_pool_price_direction_consistency(self): - """T1.3 — Pool price direction is consistent with logPriceMultiplier sign. - - If logF > 0 → poolPrice > oraclePrice - If logF < 0 → poolPrice < oraclePrice - If logF ≈ 0 → poolPrice ≈ oraclePrice - """ - api_url = _get_api_url() - price_info, trackers = await _get_price_and_trackers(api_url) - - assert price_info.pool_price is not None, "pool_price should be available for ETHRUSDPERP" - oracle_price = price_info.oracle_price - pool_price = price_info.pool_price - log_f = float(trackers.log_price_multiplier) - - # Use a small threshold to define "approximately zero" - # logF is in 18-decimal fixed point, so 1e14 ≈ 0.0001 in normalized terms - zero_threshold = 1e14 - - if log_f > zero_threshold: - assert ( - pool_price > oracle_price - ), f"logF > 0 ({log_f}) but poolPrice ({pool_price}) <= oraclePrice ({oracle_price})" - logger.info("✅ T1.3: logF > 0 → poolPrice > oraclePrice") - elif log_f < -zero_threshold: - assert ( - pool_price < oracle_price - ), f"logF < 0 ({log_f}) but poolPrice ({pool_price}) >= oraclePrice ({oracle_price})" - logger.info("✅ T1.3: logF < 0 → poolPrice < oraclePrice") - else: - # logF ≈ 0, pool price should be approximately equal to oracle - relative_diff = abs(pool_price - oracle_price) / oracle_price - assert relative_diff < 0.001, ( - f"logF ≈ 0 ({log_f}) but poolPrice ({pool_price}) differs from " - f"oraclePrice ({oracle_price}) by {relative_diff:.6f}" - ) - logger.info("✅ T1.3: logF ≈ 0 → poolPrice ≈ oraclePrice") - - -# ============================================================================ -# Level 2: Execution Price Tests (min-size IOC trades) -# ============================================================================ - - -class TestDynamicPricingExecution: - """Execution price tests using minimum-size IOC trades.""" - - @pytest.mark.asyncio - async def test_long_execution_price_above_pool_price(self, reya_tester: ReyaTester): - """T2.1 — Long trade: execution price > pool price. - - A long trade always has exec_price > poolPrice_before because: - - logF increases during a long (avg AMM price > poolPrice_before) - - positive spread pushes the price higher for buys - - Note: We compare to poolPrice (not oracle) because during rebalancing - (pool net long, logF < 0), exec_price can be < oracle. - """ - price_info: Price = await reya_tester.client.markets.get_price(SYMBOL) - assert price_info.pool_price is not None, "pool_price should be available for ETHRUSDPERP" - pool_price_before = float(price_info.pool_price) - - execution = await _execute_ioc_trade(reya_tester, is_buy=True) - exec_price = float(execution.price) - - assert ( - exec_price > pool_price_before - ), f"Long exec_price ({exec_price}) should be > poolPrice_before ({pool_price_before})" - - logger.info(f"✅ T2.1 passed: exec_price={exec_price:.2f} > poolPrice={pool_price_before:.2f}") - - @pytest.mark.asyncio - async def test_short_execution_price_below_pool_price(self, reya_tester: ReyaTester): - """T2.2 — Short trade: execution price < pool price. - - A short trade always has exec_price < poolPrice_before because: - - logF decreases during a short (avg AMM price < poolPrice_before) - - negative spread pushes the price lower for sells - """ - price_info: Price = await reya_tester.client.markets.get_price(SYMBOL) - assert price_info.pool_price is not None, "pool_price should be available for ETHRUSDPERP" - pool_price_before = float(price_info.pool_price) - - execution = await _execute_ioc_trade(reya_tester, is_buy=False) - exec_price = float(execution.price) - - price_info_after: Price = await reya_tester.client.markets.get_price(SYMBOL) - assert price_info_after.pool_price is not None, "pool_price should be available for ETHRUSDPERP" - pool_price_after = float(price_info_after.pool_price) - pool_price_upper = max(pool_price_before, pool_price_after) - - assert exec_price < pool_price_upper, ( - f"Short exec_price ({exec_price}) should be < poolPrice ({pool_price_upper}) " - f"(before={pool_price_before:.6f}, after={pool_price_after:.6f})" - ) - - logger.info(f"✅ T2.2 passed: exec_price={exec_price:.2f} < poolPrice={pool_price_upper:.2f}") - - @pytest.mark.asyncio - async def test_execution_price_bounded_by_oracle(self, reya_tester: ReyaTester): - """T2.3 — Execution price bounded within 5% of oracle. - - For a minimum-size trade, the execution price should not deviate more - than 5% from the oracle price. This catches catastrophic formula bugs - or misconfigured parameters. - """ - market_price = await reya_tester.data.current_price(SYMBOL) - oracle_price = float(market_price) - - execution = await _execute_ioc_trade(reya_tester, is_buy=True) - exec_price = float(execution.price) - - lower_bound = oracle_price * 0.95 - upper_bound = oracle_price * 1.05 - - assert lower_bound < exec_price < upper_bound, ( - f"Execution price {exec_price} outside 5% oracle bounds " - f"[{lower_bound:.2f}, {upper_bound:.2f}] (oracle={oracle_price:.2f})" - ) - - logger.info(f"✅ T2.3 passed: exec_price={exec_price:.2f} within 5% of oracle={oracle_price:.2f}") - - @pytest.mark.asyncio - async def test_spread_floor_on_execution_price(self, reya_tester: ReyaTester): - """T2.4 — Spread floor on execution price. - - For a small trade, the execution price should at minimum account for - the price spread. For a long: exec_price >= poolPrice * (1 + spread/1e18). - - This is approximate for small trades where logF change is negligible. - """ - api_url = _get_api_url() - price_info, trackers = await _get_price_and_trackers(api_url) - assert price_info.pool_price is not None, "pool_price should be available for ETHRUSDPERP" - pool_price_before = float(price_info.pool_price) - spread_normalized = float(trackers.price_spread) / 1e18 - - execution = await _execute_ioc_trade(reya_tester, is_buy=True) - exec_price = float(execution.price) - - price_info_after = await fetch_price(api_url, SYMBOL) - assert price_info_after.pool_price is not None, "pool_price should be available for ETHRUSDPERP" - pool_price_after = float(price_info_after.pool_price) - pool_price_lower = min(pool_price_before, pool_price_after) - - # Allow 0.1% tolerance for oracle price drift between API reads and trade execution - oracle_drift_tolerance = 0.001 - spread_floor = pool_price_lower * (1 + spread_normalized) * (1 - oracle_drift_tolerance) - - assert exec_price >= spread_floor, ( - f"Long exec_price ({exec_price}) should be >= poolPrice * (1 + spread) = {spread_floor:.2f} " - f"(poolPrice_min={pool_price_lower:.2f}, spread={spread_normalized:.6f})" - ) - - logger.info(f"✅ T2.4 passed: exec_price={exec_price:.2f} >= spread_floor={spread_floor:.2f}") - - -# ============================================================================ -# Level 3: Behavioral Tests (direction-only assertions) -# ============================================================================ - - -class TestDynamicPricingBehavior: - """Behavioral tests with direction-only assertions for resilience to external activity.""" - - @pytest.mark.asyncio - async def test_log_price_multiplier_changes_after_trade(self, reya_tester: ReyaTester): - """T3.1 — LogPriceMultiplier changes after a long trade. - - After executing a long IOC trade, logPriceMultiplier should increase - (pool becomes more short → logF moves in the positive direction). - - Includes a delay to account for indexer lag. - """ - trackers_before = await fetch_market_trackers(reya_tester.client.config.api_url, ETH_MARKET_ID) - log_f_before = trackers_before.log_price_multiplier - - await _execute_ioc_trade(reya_tester, is_buy=True) - - # Wait for indexer to pick up the on-chain state change - await asyncio.sleep(3.0) - - trackers_after = await fetch_market_trackers(reya_tester.client.config.api_url, ETH_MARKET_ID) - log_f_after = trackers_after.log_price_multiplier - - assert log_f_after > log_f_before, ( - f"logPriceMultiplier should increase after long trade: " f"before={log_f_before}, after={log_f_after}" - ) - - logger.info( - f"✅ T3.1 passed: logF increased from {log_f_before} to {log_f_after} " - f"(delta={log_f_after - log_f_before})" - ) - - @pytest.mark.asyncio - async def test_pool_price_shifts_after_trade(self, reya_tester: ReyaTester): - """T3.2 — Pool price shifts in the expected direction after a long trade. - - After executing a long IOC trade, the pool price should increase - (pool goes more short → logF increases → poolPrice goes up). - - Includes a delay to account for indexer lag. - """ - price_before: Price = await reya_tester.client.markets.get_price(SYMBOL) - assert price_before.pool_price is not None, "pool_price should be available for ETHRUSDPERP" - ratio_before = float(price_before.pool_price) / float(price_before.oracle_price) - - await _execute_ioc_trade(reya_tester, is_buy=True) - - # Wait for indexer to pick up the on-chain state change - await asyncio.sleep(3.0) - - price_after: Price = await reya_tester.client.markets.get_price(SYMBOL) - assert price_after.pool_price is not None, "pool_price should be available for ETHRUSDPERP" - ratio_after = float(price_after.pool_price) / float(price_after.oracle_price) - - assert ratio_after > ratio_before, ( - f"Pool/oracle ratio should increase after long trade (logF increases): " - f"before={ratio_before:.8f}, after={ratio_after:.8f}" - ) - - logger.info( - f"✅ T3.2 passed: pool/oracle ratio increased from {ratio_before:.8f} " - f"to {ratio_after:.8f} (delta={ratio_after - ratio_before:.8f})" - ) diff --git a/tests/test_perps/test_limit_orders.py b/tests/test_perps/test_limit_orders.py index a7bcf6db..3c38d76a 100644 --- a/tests/test_perps/test_limit_orders.py +++ b/tests/test_perps/test_limit_orders.py @@ -1,525 +1,315 @@ -#!/usr/bin/env python3 +""" +Perp orderbook limit-order tests using the maker/taker pattern. -from decimal import InvalidOperation +Under v2.3.0 every perp fill needs a counterparty on the opposite side. These +tests put a GTC resting order on the book via PERP_ACCOUNT_ID_1 (maker) and +hit it with an IOC from PERP_ACCOUNT_ID_2 (taker), exercising perp-specific +semantics that don't apply to spot: + +- IOC matched against a real OB resting order produces a position on the taker + side (whereas spot produces a balance change). +- ``reduce_only`` flag is perp-only; the API rejects it on spot. +- A GTC perp order rests on the book and is observable via + ``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 +perp-specific. +""" + +from __future__ import annotations + +import asyncio import pytest -from sdk.open_api import OrderStatus, RequestError, RequestErrorCode -from sdk.open_api.exceptions import ApiException, BadRequestException -from sdk.open_api.models.perp_execution import PerpExecution -from sdk.open_api.models.position import Position +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.reya_tester import limit_order_params_to_order, logger - - -async def assert_position_changes( - execution_details: PerpExecution, - reya_tester: ReyaTester, - position_before: Position | None = None, -): - """Assert that positions have changed as expected""" - if position_before is None: - position_before = Position( - exchangeId=execution_details.exchange_id, - symbol=execution_details.symbol, - accountId=execution_details.account_id, - qty="0", - side=Side.B, - avgEntryPrice="0", - avgEntryFundingValue="0", - lastTradeSequenceNumber=int(execution_details.sequence_number) - 1, - ) - position_after_qty = float(position_before.qty) + float(execution_details.qty) - - expected_average_entry_price = float(position_before.avg_entry_price) - if float(position_before.qty) == 0 or (execution_details.side == position_before.side): - expected_average_entry_price = ( - float(position_before.avg_entry_price) * float(position_before.qty) - + float(execution_details.qty) * float(execution_details.price) - ) / position_after_qty - # Wait for position to be confirmed via both REST and WebSocket - # await reya_tester.wait_for_position(execution_details.symbol) - - await reya_tester.check.position( - symbol=execution_details.symbol, - expected_exchange_id=execution_details.exchange_id, - expected_account_id=execution_details.account_id, - expected_qty=execution_details.qty, - expected_side=execution_details.side, - expected_avg_entry_price=str(expected_average_entry_price), - expected_last_trade_sequence_number=int(execution_details.sequence_number), - ) - logger.info("✅ New position recorded correctly") - +from tests.helpers.liquidity_detector import skip_if_external_liquidity +from tests.helpers.reya_tester import logger -@pytest.mark.asyncio -@pytest.mark.parametrize( - "test_qty, test_is_buy", - [ - (0.01, True), - (0.01, False), - ], -) -async def test_success_ioc(reya_tester: ReyaTester, test_qty, test_is_buy): - """Test creating an order and confirming execution""" - symbol = "ETHRUSDPERP" - - # Get current prices to determine order parameters - market_price = await reya_tester.data.current_price() - logger.info(f"Market price: {market_price}") - - # Get positions before order - await reya_tester.check.position_not_open(symbol) - await reya_tester.check.no_open_orders() - - price_with_offset = float(market_price) * 1.1 if test_is_buy else float(market_price) * 0.9 - limit_order_params = LimitOrderParameters( - symbol=symbol, - is_buy=test_is_buy, - limit_px=str(price_with_offset), - qty=str(test_qty), - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) +PERP_SYMBOL = "ETHRUSDPERP" +PERP_QTY = "0.01" - logger.info("Trade confirmation task") - await reya_tester.orders.create_limit(limit_order_params) +def _maker_buy_price(market_price: float) -> str: + """Generous bid: maker willing to pay 1% above oracle so IOC sells from taker hit.""" + return str(round(market_price * 1.01, 2)) - # Validate - expected_order = limit_order_params_to_order(limit_order_params, reya_tester.account_id) - execution = await reya_tester.wait.for_order_execution(expected_order) - assert execution is not None - await reya_tester.check.no_open_orders() - order_execution_details = await reya_tester.check.order_execution(execution, expected_order) - await assert_position_changes(order_execution_details, reya_tester) - logger.info("Order execution test complete") +def _maker_sell_price(market_price: float) -> str: + """Generous ask: maker willing to sell 1% below oracle so IOC buys from taker hit.""" + return str(round(market_price * 0.99, 2)) @pytest.mark.asyncio -async def test_failure_ioc_with_reduce_only_on_empty_position(reya_tester: ReyaTester): - """Test 1: Try IOC with reduce_only flag but the position is actually expanding (should error)""" - symbol = "ETHRUSDPERP" - - # SETUP - market_price = await reya_tester.data.current_price() - logger.info(f"Market price: {market_price}") - - test_qty = 0.01 - test_is_buy = True - price_with_offset = float(market_price) * 1.1 if test_is_buy else float(market_price) * 0.9 - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - order_params_reduce = LimitOrderParameters( - symbol=symbol, - is_buy=test_is_buy, - limit_px=str(price_with_offset), - qty=str(test_qty), - time_in_force=TimeInForce.IOC, - reduce_only=True, +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", ) - try: - await reya_tester.orders.create_limit(order_params_reduce) - assert False, "Order should not have been accepted with reduce_only flag on no position" - except BadRequestException as e: - assert e.data is not None - requestError: RequestError = e.data - # API returns human-readable error message - assert "Reduce-Only" in requestError.message or "ReduceOnly" in requestError.message - assert requestError.error == RequestErrorCode.CREATE_ORDER_OTHER_ERROR - -@pytest.mark.asyncio -async def test_failure_ioc_with_invalid_limit_px(reya_tester: ReyaTester): - """Try IOC with limit price on the opposite side of the market, should revert""" - symbol = "ETHRUSDPERP" - - # SETUP - market_price = await reya_tester.data.current_price() - logger.info(f"Market price: {market_price}") - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - test_qty = 0.01 - test_is_buy = True - invalid_price = float(market_price) * 0.9 # Price below market for buy order (should be rejected) - order_params_invalid = LimitOrderParameters( - symbol=symbol, - is_buy=test_is_buy, - limit_px=str(invalid_price), - qty=str(test_qty), - time_in_force=TimeInForce.IOC, - reduce_only=False, + # 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, + ) ) - try: - response = await reya_tester.orders.create_limit(order_params_invalid) - # If we get here, the test failed - order should not have been accepted - assert not response, f"Order should not have been accepted with invalid price {invalid_price} for buy order" - except BadRequestException as e: - assert e.data is not None - requestError: RequestError = e.data - assert requestError.message == "UnacceptableOrderPrice" - assert requestError.error == RequestErrorCode.CREATE_ORDER_OTHER_ERROR - - -@pytest.mark.asyncio -async def test_failure_ioc_with_input_validation(reya_tester: ReyaTester): - """Try various invalid inputs""" - symbol = "ETHRUSDPERP" - - test_cases = [ - { - "name": "Invalid symbol", - "params": { - "symbol": 100000, - "is_buy": True, - "limit_px": "100", - "qty": "0.01", - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Wrong symbol", - "params": { - "symbol": "wrong", - "is_buy": True, - "limit_px": "100", - "qty": "0.01", - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Empty symbol", - "params": { - "symbol": "", # Empty symbol should fail validation - "is_buy": True, - "limit_px": "100", - "qty": "0.01", - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Missing symbol", - "params": { - # symbol is missing - should raise KeyError - "is_buy": True, - "limit_px": "100", - "qty": "0.01", - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Missing is_buy", - "params": { - "symbol": symbol, - # is_buy is missing - should raise KeyError - "limit_px": "100", - "qty": "0.01", - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Invalid is_buy", - "params": { - "symbol": symbol, - "is_buy": "invalid", - "limit_px": "100", - "qty": "0.01", - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Missing qty", - "params": { - "symbol": symbol, - "is_buy": True, - "limit_px": "100", - # qty is missing - should raise KeyError - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Invalid qty", - "params": { - "symbol": symbol, - "is_buy": True, - "limit_px": "100", - "qty": "invalid", - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Zero qty", - "params": { - "symbol": symbol, - "is_buy": True, - "limit_px": "100", - "qty": "0", - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Negative qty", - "params": { - "symbol": symbol, - "is_buy": True, - "limit_px": "100", - "qty": "-0.01", - "time_in_force": TimeInForce.GTC, - "reduce_only": False, - }, - }, - { - "name": "Missing time_in_force", - "params": { - "symbol": symbol, - "is_buy": True, - "limit_px": "100", - "qty": "0.01", - # time_in_force is missing - should raise KeyError - "reduce_only": False, - }, - }, - # IOC-specific validation (reduceOnly IS sent for IOC orders) - { - "name": "Invalid reduce_only for IOC", - "params": { - "symbol": symbol, - "is_buy": True, - "limit_px": "100", - "qty": "0.01", - "time_in_force": TimeInForce.IOC, - "reduce_only": "invalid", # Invalid type - should fail validation - }, - }, - ] - - for test_case in test_cases: - logger.info(f"Testing: {test_case['name']}") - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - try: - # Build params dict - use values from test case, no defaults for required fields - params = test_case["params"] - assert isinstance(params, dict) - order_params_test = LimitOrderParameters( - symbol=params["symbol"], - is_buy=params["is_buy"], - limit_px=params["limit_px"], - qty=params["qty"], - time_in_force=params["time_in_force"], - reduce_only=params.get("reduce_only"), - ) - await reya_tester.orders.create_limit(order_params_test) - assert False, f"{test_case['name']} should have failed" - except (KeyError, TypeError, ValueError, InvalidOperation) as e: - # Missing required field, SDK validation error, or decimal conversion error - logger.info(f"Pass: Expected error for {test_case['name']}: {type(e).__name__}: {e}") - except ApiException as e: - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - logger.info(f"Pass: Expected error for {test_case['name']}: {e}") - - logger.info("input_validation test completed successfully") - await reya_tester.orders.close_all(fail_if_none=False) - - -@pytest.mark.asyncio -async def test_success_gtc_with_order_and_cancel(reya_tester: ReyaTester): - """1 GTC order, long, close right after creation""" - symbol = "ETHRUSDPERP" - - # SETUP - capture sequence number BEFORE any actions - last_sequence_before = await reya_tester.get_last_perp_execution_sequence_number() - - market_price = await reya_tester.data.current_price() - logger.info(f"Market price: {market_price}") - test_qty = 0.01 - - order_params_buy = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(0), # wide price - qty=str(test_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, + ) ) - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - # Buy order slightly above market price to ensure it gets filled - assert order_params_buy.limit_px is not None - buy_order_id = await reya_tester.orders.create_limit(order_params_buy) - - assert buy_order_id is not None - - # Wait for order creation to be confirmed via both REST and WebSocket - await reya_tester.wait.for_order_creation(buy_order_id) - expected_order = limit_order_params_to_order(order_params_buy, reya_tester.account_id) - await reya_tester.check.open_order_created(buy_order_id, expected_order) - await reya_tester.check.position_not_open(symbol) - - # cancel order - await reya_tester.client.cancel_order(order_id=buy_order_id) - - # Note: this confirms trade has been registered, not neccesarely position - cancelled_order_id = await reya_tester.wait.for_order_state(buy_order_id, OrderStatus.CANCELLED) - assert cancelled_order_id == buy_order_id, "GTC order was not cancelled" - - await reya_tester.check.position_not_open(symbol) - await reya_tester.check_no_order_execution_since(last_sequence_before) - await reya_tester.check.no_open_orders() - - logger.info("GTC order cancel test completed successfully") - - -@pytest.mark.asyncio -async def test_success_gtc_orders_with_execution(reya_tester: ReyaTester): - """Single GTC order filled against the pool (perp AMM)""" - symbol = "ETHRUSDPERP" - - # Get current prices to determine order parameters - market_price = await reya_tester.data.current_price() - - # For perp markets, GTC orders can fill against the pool (AMM) - # Set limit price above market to ensure it crosses the spread and fills - order_params_buy = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.01), # 1% above market to cross spread - qty=str(0.01), - time_in_force=TimeInForce.GTC, + 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, ) - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - # BUY - assert order_params_buy.limit_px is not None - buy_order_id = await reya_tester.orders.create_limit(order_params_buy) - logger.info(f"Created GTC BUY order with ID: {buy_order_id} at price {order_params_buy.limit_px}") - - await reya_tester.wait.for_order_state(buy_order_id, OrderStatus.FILLED) - expected_order = limit_order_params_to_order(order_params_buy, reya_tester.account_id) - execution = await reya_tester.wait.for_order_execution(expected_order) - order_execution_details = await reya_tester.check.order_execution(execution, expected_order) - await assert_position_changes(order_execution_details, reya_tester) - - logger.info("GTC market execution test completed successfully") - await reya_tester.orders.close_all(fail_if_none=False) - @pytest.mark.asyncio -async def test_integration_gtc_with_market_execution(reya_tester: ReyaTester): - """2 GTC orders, long and short, very close to market price and wait for execution""" - symbol = "ETHRUSDPERP" - - # Get the last execution BEFORE creating orders to compare later - last_execution_before = await reya_tester.get_last_wallet_perp_execution() - last_sequence_before = last_execution_before.sequence_number if last_execution_before else 0 - logger.info(f"Last execution sequence before test: {last_sequence_before}") - - # Get current prices to determine order parameters - market_price = await reya_tester.data.current_price() - - order_params_buy = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 0.999), - qty=str(0.01), - time_in_force=TimeInForce.GTC, +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 - # BUY - assert order_params_buy.limit_px is not None - buy_order_id = await reya_tester.orders.create_limit(order_params_buy) - order_params_sell = LimitOrderParameters( - symbol=symbol, - is_buy=False, - limit_px=str(float(market_price) * 1.0001), - qty=str(order_params_buy.qty), - time_in_force=TimeInForce.GTC, +@pytest.mark.asyncio +async def test_perp_reduce_only_rejected_without_position( + perp_maker_tester: ReyaTester, + perp_taker_tester: ReyaTester, +) -> None: + """``reduce_only=True`` IOC must not open a fresh position from zero. + + Settlement signal sourced from the WebSocket ``/executionBusts`` feed, + NOT the REST ``createOrder`` response status. This is intentional in + the off-chain design (confirmed by Daniel in + Reya-Labs/reya-off-chain-monorepo#2575): the REST response status + only signals "ME matched" — settlement failures (including on-chain + ``ReduceOnlyConditionFailed`` reverts) surface exclusively on the + bust + executions WS channels. A test that inspects only the REST + status would have a false-positive on every reverted settlement, + because the REST response will return ``FILLED`` even when the chain + rolled back the fill. + + Enforced on-chain in reya-network ``orders-gateway/src/libraries/ + ExecutePartialFill.sol:159-174``: when ``accountOrder.reduceOnly`` is + set, ``ExecutePartialFill`` reads the taker's live perp base from + ``IPassivePerpInformationModule.getUpdatedPositionInfo`` and reverts + with ``Errors.ReduceOnlyConditionFailed`` if the base is zero + (``orders-gateway/src/libraries/execute-order-types/Utils.sol:46-51``). + The ME currently has no reduce-only pre-check — the proto carries the + flag through and on-chain settlement is the enforcement layer. (An ME + pre-check is in development; once it lands races narrow but on-chain + remains authoritative.) + + To deterministically exercise the on-chain check, we place a maker + SELL at oracle*1.04 *before* submitting the taker IOC BUY at + oracle*1.05. Without this setup the test would silently pass via + the ME's CANCELLED-no-counterparty branch whenever no external ask + liquidity is reachable — never actually verifying the invariant. + + Why oracle*1.04 for the maker: + - below taker IOC limit (oracle*1.05) so they can cross + - well above any sane external bid (bids sit below mid) so the + maker won't be crossed by external flow before the taker arrives + - inside the ±5% CB so the ME accepts it + + If external asks at lower prices exist, the IOC will hit those first + (price-time priority) and our maker just rests until cleanup. Either + path reaches chain. + + Failure modes this test catches: + - No bust event ever arrives (within ``BUST_TIMEOUT_S``) → on-chain + reduce-only check didn't fire or the bust indexer is broken + - Bust arrives but reason doesn't mention reduce-only/position → + a different revert reached chain (e.g. nonce, signature) — the + test's invariant isn't being exercised + - Taker position ends up non-zero → chain DID settle the fill (the + reduce-only check was skipped) — this is the headline regression + the test exists to catch + """ + market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) + + # Skip if an external MM is on the book: the setup maker SELL at oracle*1.04 + # below would cross any external bid above that price and fill immediately + # instead of resting, so the reduce-only IOC would settle against external + # liquidity rather than exercise the zero-position on-chain check (and that + # settlement currently 500s). Mirrors `test_perp_ioc_taker_buy_matches_maker_sell` + # and the other controlled-book tests that self-skip on a dirty devnet book. + await skip_if_external_liquidity( + perp_taker_tester.data, + PERP_SYMBOL, + market_price, + reason_prefix="test_perp_reduce_only_rejected_without_position", ) - assert order_params_sell.limit_px is not None - sell_order_id = await reya_tester.orders.create_limit(order_params_sell) - - assert buy_order_id is not None - assert sell_order_id is not None - - # Wait for trade confirmation on either order (whichever fills first) - # Check if there's a NEW execution (with higher sequence_number than before) - order_execution_details = await reya_tester.get_last_wallet_perp_execution() - logger.info(f"Last execution after orders: {order_execution_details}") - - # Only consider it filled if there's a NEW execution (sequence_number increased) - is_new_execution = ( - order_execution_details is not None and order_execution_details.sequence_number > last_sequence_before + await perp_taker_tester.check.position_not_open(PERP_SYMBOL) + + # Guarantee the IOC has a counterparty so the on-chain check actually + # runs — see docstring for rationale. + maker_order_id = await perp_maker_tester.orders.create_limit( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=False, + limit_px=str(round(market_price * 1.04, 2)), + qty=PERP_QTY, + time_in_force=TimeInForce.GTC, + ) ) + assert maker_order_id is not None + await perp_maker_tester.wait.for_order_creation(order_id=maker_order_id) + + # Submit the reduce-only IOC. Two paths the on-chain reduce-only revert + # can surface, both acceptable for proving the invariant: + # + # 1. REST FILLED + WS bust event (the design intent — REST status + # conveys "ME matched" only, settlement signal lives on the + # `/executionBusts` channel) + # 2. REST 4xx with the decoded reason in the body (the transitional + # consumer-fix behavior — gets reverted to path 1 eventually) + # + # The test accepts either path because we want it green during the + # deploy-timing window when devnet may be running either version of + # the consumer. Once the revert has propagated everywhere and we're + # confident path 2 is gone, the try/except can be removed and the + # test simplifies to just the path-1 branch. + try: + response = await perp_taker_tester.client.create_limit_order( + 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=True, + ) + ) + assert response is not None + assert response.order_id is not None, "ME should have returned an order_id" + logger.info( + "REST createOrder response (status not authoritative): " + f"status={response.status}, order_id={response.order_id}, exec_qty={response.exec_qty}" + ) - if is_new_execution: + # Path 1: authoritative invariant via the bust feed. + bust = await perp_taker_tester.wait.for_execution_bust( + order_id=str(response.order_id), + timeout=15, + ) + bust_reason_lower = (bust.reason or "").lower() + assert ( + "reduce" in bust_reason_lower or "position" in bust_reason_lower + ), f"expected reduce-only revert reason on bust, got: {bust.reason!r}" + logger.info(f"✅ Bust feed surfaced expected reason: {bust.reason}") + + except ApiException as e: + # Path 2: transitional — REST surfaces the decoded reason directly. + err = str(e).lower() + assert "reduce" in err or "position" in err, f"REST 4xx but message didn't surface reduce-only reason: {e}" logger.info( - f"Order was filled (new sequence: {order_execution_details.sequence_number} > {last_sequence_before})" + "✅ Reduce-only invariant verified via REST 4xx (transitional " f"consumer-fix path): {type(e).__name__}" ) - if order_execution_details.side == Side.B: - await assert_position_changes(order_execution_details, reya_tester) - expected_buy_order = limit_order_params_to_order(order_params_buy, reya_tester.account_id) - execution = await reya_tester.wait.for_order_execution(expected_buy_order) - await reya_tester.check.order_execution(execution, expected_buy_order) - else: - await assert_position_changes(order_execution_details, reya_tester) - expected_sell_order = limit_order_params_to_order(order_params_sell, reya_tester.account_id) - execution = await reya_tester.wait.for_order_execution(expected_sell_order) - await reya_tester.check.order_execution(execution, expected_sell_order) - - await reya_tester.wait.for_order_state(buy_order_id, OrderStatus.CANCELLED) - await reya_tester.wait.for_order_state(sell_order_id, OrderStatus.CANCELLED) - else: - logger.info("Order was not filled (no new execution)") - await reya_tester.wait.for_order_creation(buy_order_id) - await reya_tester.wait.for_order_creation(sell_order_id) - expected_buy_order = limit_order_params_to_order(order_params_buy, reya_tester.account_id) - expected_sell_order = limit_order_params_to_order(order_params_sell, reya_tester.account_id) - await reya_tester.check.open_order_created(buy_order_id, expected_buy_order) - await reya_tester.check.open_order_created(sell_order_id, expected_sell_order) - - logger.info("GTC market execution test completed successfully") - await reya_tester.orders.close_all() + + # Belt-and-suspenders: confirm chain truth — no position formed on the + # taker. If the bust fired but a position still shows up here, that + # would indicate the bust feed reported a phantom failure while the + # fill actually settled — i.e. an indexer-vs-chain divergence worth + # investigating separately. + await asyncio.sleep(0.5) + await perp_taker_tester.check.position_not_open(PERP_SYMBOL) @pytest.mark.asyncio -async def test_failure_cancel_gtc_when_order_is_not_found(reya_tester: ReyaTester): - """Test cancelling a non-existent order returns appropriate error""" - await reya_tester.check.no_open_orders() +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) - try: - await reya_tester.client.cancel_order(order_id="non_existent_order_id_12345") - assert False, "Cancel should have failed for non-existent order" - except BadRequestException as e: - assert e.data is not None - request_error: RequestError = e.data - assert request_error.message is not None - assert request_error.message.startswith( - "Missing order with id non_existent_order_id_12345" - ), f"Expected message to start with 'Missing order with id', got: {request_error.message}" - assert request_error.error == RequestErrorCode.CANCEL_ORDER_OTHER_ERROR - - await reya_tester.check.no_open_orders() - logger.info("✅ Cancel non-existent order test completed successfully") + 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/test_perps/test_market_data.py index 55a9965a..d243388e 100644 --- a/tests/test_perps/test_market_data.py +++ b/tests/test_perps/test_market_data.py @@ -1,4 +1,14 @@ #!/usr/bin/env python3 +"""Perp market-data REST endpoints — adapted to the v2.3.0 schema. + +Field changes vs the AMM era: +- ``MarketSummary``: ``longOiQty`` / ``shortOiQty`` collapsed to a single ``oiQty``; + ``fundingRateVelocity`` removed; ``throttledOraclePrice`` → ``markPrice``; + ``throttledPoolPrice`` → ``throttledMidPrice``. +- ``Price.poolPrice`` is now optional — present only while the AMM-era pool-price + publisher is still running on a market; absent on perpOB-only markets. +""" + import re import time @@ -48,8 +58,12 @@ async def test_market_price(reya_tester: ReyaTester): assert price is not None assert price.symbol == symbol assert 0 < float(price.oracle_price) < 10**18 - assert price.pool_price is not None - assert 0 < float(price.pool_price) < 10**18 + + # ``pool_price`` is optional under v2.3.0 — only set while the AMM + # pool-price publisher still ticks. Validate when present. + if price.pool_price is not None: + assert 0 < float(price.pool_price) < 10**18 + current_time = int(time.time()) assert price.updated_at / 1000 > current_time - 60 @@ -63,28 +77,32 @@ async def test_all_prices(reya_tester: ReyaTester): for sample_price in prices: assert sample_price.symbol is not None and len(sample_price.symbol) > 0, "Symbol should not be empty" assert 0 <= float(sample_price.oracle_price) < 10**18, "Oracle price should be a valid positive number" - if "PERP" in sample_price.symbol: + if sample_price.pool_price is not None: assert ( 0 <= float(sample_price.pool_price) < 10**18 - if sample_price.pool_price and "PERP" in sample_price.symbol - else True - ), "Pool price should be a valid positive number" + ), "Pool price should be a valid positive number when present" current_time = int(time.time() * 1000) assert sample_price.updated_at > current_time - (60 * 60 * 1000), "Updated timestamp should be within last hour" assert sample_price.updated_at <= current_time + (60 * 1000), "Updated timestamp should not be in future" + symbols = {price.symbol for price in prices} assert "ETHRUSDPERP" in symbols, "Should include ETHRUSDPERP in all prices" @pytest.mark.asyncio async def test_market_summary(reya_tester: ReyaTester): + """Validate the v2.3.0 MarketSummary shape: oiQty (single), markPrice, throttledMidPrice. + + Removed fields from the AMM era: longOiQty / shortOiQty / fundingRateVelocity / + throttledOraclePrice / throttledPoolPrice. See specs commit + ``8cedb97 feat: update MarketSummary schema - remove and rename fields``. + """ symbol = "ETHRUSDPERP" market_summary = await reya_tester.client.markets.get_market_summary(symbol) assert market_summary is not None assert market_summary.symbol == symbol - assert float(market_summary.oi_qty) >= 0, "OI quantity should be a valid number" - assert float(market_summary.long_oi_qty) >= 0, "Long OI quantity should be a valid number" - assert float(market_summary.short_oi_qty) >= 0, "Short OI quantity should be a valid number" + + assert float(market_summary.oi_qty) >= 0, "OI quantity should be a valid non-negative number" assert -(10**3) < float(market_summary.funding_rate) < 10**3, "Funding rate should be a valid number" assert ( @@ -93,23 +111,26 @@ async def test_market_summary(reya_tester: ReyaTester): assert ( market_summary.short_funding_value.replace(".", "", 1).lstrip("-").isdigit() ), "Short funding value should be a valid number" - assert ( - market_summary.funding_rate_velocity.replace(".", "", 1).lstrip("-").isdigit() - ), "Funding rate velocity should be a valid number" assert float(market_summary.volume24h) >= 0, "Volume 24h should be a valid number" - assert market_summary.px_change24h is not None - assert ( - market_summary.px_change24h.replace(".", "", 1).lstrip("-").isdigit() - ), "Price change 24h should be a valid number" + if market_summary.px_change24h is not None: + assert ( + market_summary.px_change24h.replace(".", "", 1).lstrip("-").isdigit() + ), "Price change 24h should be a valid number" assert market_summary.updated_at / 1000 > time.time() - 86400 * 2, "Updated timestamp should be valid" - assert market_summary.throttled_pool_price is not None - assert float(market_summary.throttled_pool_price) > 0, "Pool price should be positive" - assert market_summary.throttled_oracle_price is not None - assert float(market_summary.throttled_oracle_price) > 0, "Oracle price should be positive" - assert market_summary.prices_updated_at is not None - assert market_summary.prices_updated_at / 1000 > time.time() - 86400 * 2, "Prices updated timestamp should be valid" + + # markPrice and throttledMidPrice are optional but should be populated for an + # active market — a perpOB market that's been quoted should always emit both. + if market_summary.mark_price is not None: + assert float(market_summary.mark_price) > 0, "Mark price should be positive" + if market_summary.throttled_mid_price is not None: + assert float(market_summary.throttled_mid_price) > 0, "Throttled mid price should be positive" + + if market_summary.prices_updated_at is not None: + assert ( + market_summary.prices_updated_at / 1000 > time.time() - 86400 * 2 + ), "Prices updated timestamp should be valid" @pytest.mark.asyncio @@ -125,7 +146,6 @@ async def test_candles(reya_tester: ReyaTester): for resolution in ["1m", "5m", "15m", "1h", "4h", "1d"]: logger.info(f"Testing resolution: {resolution}") - candles_count = 200 resolution_in_seconds = ( 60 if resolution == "1m" @@ -144,7 +164,18 @@ async def test_candles(reya_tester: ReyaTester): symbol=symbol, resolution=resolution, end_time=current_time ) assert candles is not None - assert len(candles.t) == candles_count + # Portability: the prior `== 200` assertion implicitly required the + # env to have ~200 days of history for the 1d resolution, which + # never holds on a freshly-deployed env (devnet1). 200 was arbitrary + # — the real correctness invariants below (existence, array-shape + # consistency, chronological order, resolution-aligned gaps) are + # what we actually care about. Reviewers: don't tighten this back + # to an exact count without scoping it per-env to one that can + # actually satisfy it. + candles_count = len(candles.t) + assert candles_count > 0, f"expected at least one {resolution} candle for {symbol}" + # All OHLC arrays must be the same length as the timestamp array + # (consistency of the API response). assert len(candles.c) == candles_count assert len(candles.o) == candles_count assert len(candles.h) == candles_count @@ -163,29 +194,39 @@ async def test_candles(reya_tester: ReyaTester): @pytest.mark.asyncio async def test_market_perp_executions(reya_tester: ReyaTester): + """Validate the v2.3.0 PerpExecution shape — taker/maker fields, taker_fee, etc. + + Renames from the AMM era: ``accountId`` → ``takerAccountId``, ``fee`` → ``takerFee``. + New optional fields: ``makerAccountId``, ``takerOrderId``, ``makerOrderId``, + ``makerFee``, ``makerOpeningFee``, ``makerRealizedPnl``, ``makerPriceVariationPnl``, + ``makerFundingPnl``. Execution type ``DUST`` was added. + """ symbol = "ETHRUSDPERP" executions = await reya_tester.client.markets.get_market_perp_executions(symbol) assert executions is not None - assert len(executions.data) > 0 + if not executions.data: + pytest.skip("No perp executions on this market yet") execution = executions.data[0] assert execution.symbol == symbol assert 0 < float(execution.price) < 10**7, "Price should be a valid positive number" assert 0 < float(execution.qty) < 10**10, "Quantity should be a valid positive number" - assert 0 <= float(execution.fee) < 10**6, "Fee should be a valid non-negative number" - assert execution.side in [ - "B", - "A", - ], f"Side should be B or A, got: {execution.side}" + # ``taker_fee`` is signed: positive means the taker paid; negative would be a rebate. + # We bound by absolute value rather than asserting non-negative. + assert abs(float(execution.taker_fee)) < 10**6, "Taker fee should be within sane bounds" + assert execution.side in ["B", "A"], f"Side should be B or A, got: {execution.side}" assert execution.sequence_number > 0, "Sequence number should be positive" - assert execution.account_id > 0, "Account ID should be positive" + assert execution.taker_account_id > 0, "Taker account ID should be positive" assert execution.exchange_id > 0, "Exchange ID should be positive" current_time = int(time.time() * 1000) assert execution.timestamp > current_time - (30 * 24 * 60 * 60 * 1000), "Timestamp should be recent" assert execution.timestamp <= current_time + (60 * 1000), "Timestamp should not be in future" + # ``DUST`` was added in v2.3.0; legacy ``ORDER_MATCH`` and ``LIQUIDATION`` still apply, plus ``ADL``. assert execution.type in [ "ORDER_MATCH", "LIQUIDATION", + "ADL", + "DUST", ], f"Unexpected execution type: {execution.type}" @@ -253,34 +294,3 @@ async def test_global_fee_parameters(reya_tester: ReyaTester): assert 0 <= float(global_fees.referee_discount) <= 1 assert 0 <= float(global_fees.referrer_rebate) <= 1 assert 0 <= float(global_fees.affiliate_referrer_rebate) <= 1 - - -@pytest.mark.asyncio -async def test_liquidity_parameters(reya_tester: ReyaTester): - """Test getting liquidity parameters.""" - liquidity_params = await reya_tester.client.reference.get_liquidity_parameters() - assert liquidity_params is not None - - params = {} - for param in liquidity_params: - params[param.symbol] = param - - assert param.symbol is not None and len(param.symbol) > 0, "Symbol should not be empty" - assert "PERP" in param.symbol, f"Symbol should be a perpetual contract (contain PERP), got: {param.symbol}" - - assert 0 < float(param.depth) <= 10_000_000, f"Depth should be positive and reasonable, got: {param.depth}" - - assert ( - 0 <= float(param.velocity_multiplier) <= 50000 - ), f"Velocity multiplier should be non-negative and reasonable, got: {param.velocity_multiplier}" - - assert len(params.keys()) > 0, "Should have at least one liquidity parameter" - assert "ETHRUSDPERP" in params, "ETHRUSDPERP should be in liquidity parameters" - - eth_param = params.get("ETHRUSDPERP") - assert eth_param - assert eth_param.symbol == "ETHRUSDPERP", f"Expected ETHRUSDPERP symbol, got: {eth_param.symbol}" - assert float(eth_param.depth) > 0, f"ETH depth should be positive, got: {eth_param.depth}" - assert ( - float(eth_param.velocity_multiplier) > 0 - ), f"ETH velocity multiplier should be positive, got: {eth_param.velocity_multiplier}" diff --git a/tests/test_perps/test_position_management.py b/tests/test_perps/test_position_management.py index 1adcc3ca..5027216e 100644 --- a/tests/test_perps/test_position_management.py +++ b/tests/test_perps/test_position_management.py @@ -1,5 +1,19 @@ -#!/usr/bin/env python3 -"""Tests for perp position management edge cases (increase, decrease, partial close).""" +""" +Perp position-management tests using the maker/taker pattern. + +Under perp orderbook every fill needs a counterparty, so position-formation +tests can no longer use a single account that hits the AMM pool. These tests +have ``perp_maker_tester`` rest GTC liquidity and ``perp_taker_tester`` cross +against it via IOC, then assert position state on the taker. + +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. +""" + +from __future__ import annotations import pytest @@ -8,345 +22,369 @@ 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.reya_tester import limit_order_params_to_order, logger +from tests.helpers.liquidity_detector import skip_if_external_liquidity +from tests.helpers.reya_tester import logger + +PERP_SYMBOL = "ETHRUSDPERP" +PERP_QTY = "0.01" + + +# All tests in this module rest a maker order at oracle ±1% and then cross it +# with an opposite-side taker IOC at oracle ±5%. That pattern assumes nobody +# else is on the book — see the docstring of `skip_if_external_liquidity` for +# the rationale and the mirroring spot-suite precedent. The helpers below call +# the guard once before placing the maker order; if external liquidity is +# present, the test is skipped with a clear message rather than failing with +# an opaque ``RuntimeError: Order X not created after 10 seconds`` (which is +# what happens when the maker crosses external MM liquidity and gets +# instantly filled instead of resting). + + +async def _rest_maker_sell(maker: ReyaTester, market_price: float, qty: str = PERP_QTY) -> str: + """Place a maker sell at 1% below oracle. Returns the maker order_id. + + Skips the calling test if external bid/ask liquidity is on the book — + the −1% sell would cross any bid within the ±5% circuit-breaker band + and never rest. + """ + await skip_if_external_liquidity(maker.data, PERP_SYMBOL, market_price, reason_prefix="_rest_maker_sell") + price = str(round(market_price * 0.99, 2)) + order_id = await maker.orders.create_limit( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=False, + limit_px=price, + qty=qty, + time_in_force=TimeInForce.GTC, + ) + ) + assert order_id is not None + await maker.wait.for_order_creation(order_id=order_id) + return order_id + + +async def _rest_maker_buy(maker: ReyaTester, market_price: float, qty: str = PERP_QTY) -> str: + """Place a maker buy at 1% above oracle. Returns the maker order_id. + + Skips the calling test if external bid/ask liquidity is on the book — + the +1% buy would cross any ask within the ±5% circuit-breaker band + and never rest. + """ + await skip_if_external_liquidity(maker.data, PERP_SYMBOL, market_price, reason_prefix="_rest_maker_buy") + price = str(round(market_price * 1.01, 2)) + order_id = await maker.orders.create_limit( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=True, + limit_px=price, + qty=qty, + time_in_force=TimeInForce.GTC, + ) + ) + assert order_id is not None + await maker.wait.for_order_creation(order_id=order_id) + return order_id @pytest.mark.asyncio -async def test_position_increase_long(reya_tester: ReyaTester): - """Test increasing a long position by adding more""" - symbol = "ETHRUSDPERP" - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - market_price = await reya_tester.data.current_price() - initial_qty = "0.01" - - initial_order = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.1), - qty=initial_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, +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( + 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 reya_tester.orders.create_limit(initial_order) - expected_order = limit_order_params_to_order(initial_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_order) - - await reya_tester.check.position( - symbol=symbol, + await perp_taker_tester.check.position( + symbol=PERP_SYMBOL, expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=initial_qty, + expected_account_id=perp_taker_tester.account_id, + expected_qty=PERP_QTY, expected_side=Side.B, ) + logger.info("✅ taker holds a long after lifting maker sell") - add_qty = "0.01" - add_order = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.1), - qty=add_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - await reya_tester.orders.create_limit(add_order) - expected_add_order = limit_order_params_to_order(add_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_add_order) +@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) + 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, + ) + ) - expected_total_qty = str(float(initial_qty) + float(add_qty)) - await reya_tester.check.position( - symbol=symbol, + await perp_taker_tester.check.position( + symbol=PERP_SYMBOL, expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=expected_total_qty, - expected_side=Side.B, + expected_account_id=perp_taker_tester.account_id, + expected_qty=PERP_QTY, + expected_side=Side.A, ) - logger.info("✅ Position increase (long) test completed successfully") - @pytest.mark.asyncio -async def test_position_increase_short(reya_tester: ReyaTester): - """Test increasing a short position by adding more""" - symbol = "ETHRUSDPERP" - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - market_price = await reya_tester.data.current_price() - initial_qty = "0.01" - - initial_order = LimitOrderParameters( - symbol=symbol, - is_buy=False, - limit_px=str(float(market_price) * 0.9), - qty=initial_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, +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) + 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 reya_tester.orders.create_limit(initial_order) - expected_order = limit_order_params_to_order(initial_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_order) + # 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 reya_tester.check.position( - symbol=symbol, + expected_total = str(float(PERP_QTY) * 2) + await perp_taker_tester.check.position( + symbol=PERP_SYMBOL, expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=initial_qty, - expected_side=Side.A, + expected_account_id=perp_taker_tester.account_id, + expected_qty=expected_total, + expected_side=Side.B, ) - add_qty = "0.01" - add_order = LimitOrderParameters( - symbol=symbol, - is_buy=False, - limit_px=str(float(market_price) * 0.9), - qty=add_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, + +@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) + 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 reya_tester.orders.create_limit(add_order) - expected_add_order = limit_order_params_to_order(add_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_add_order) + 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, + ) + ) - expected_total_qty = str(float(initial_qty) + float(add_qty)) - await reya_tester.check.position( - symbol=symbol, + expected_total = str(float(PERP_QTY) * 2) + await perp_taker_tester.check.position( + symbol=PERP_SYMBOL, expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=expected_total_qty, + expected_account_id=perp_taker_tester.account_id, + expected_qty=expected_total, expected_side=Side.A, ) - logger.info("✅ Position increase (short) test completed successfully") - @pytest.mark.asyncio -async def test_position_partial_close_long(reya_tester: ReyaTester): - """Test partially closing a long position""" - symbol = "ETHRUSDPERP" - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - market_price = await reya_tester.data.current_price() +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) + market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) initial_qty = "0.02" + close_qty = "0.01" - initial_order = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.1), - qty=initial_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - - await reya_tester.orders.create_limit(initial_order) - expected_order = limit_order_params_to_order(initial_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_order) - - await reya_tester.check.position( - symbol=symbol, - expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=initial_qty, - expected_side=Side.B, + # Open 0.02 long + 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, + ) ) - close_qty = "0.01" - close_order = LimitOrderParameters( - symbol=symbol, - is_buy=False, - limit_px=str(float(market_price) * 0.9), - qty=close_qty, - time_in_force=TimeInForce.IOC, - reduce_only=True, + # 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 reya_tester.orders.create_limit(close_order) - expected_close_order = limit_order_params_to_order(close_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_close_order) - - expected_remaining_qty = str(float(initial_qty) - float(close_qty)) - await reya_tester.check.position( - symbol=symbol, + expected_remaining = str(float(initial_qty) - float(close_qty)) + await perp_taker_tester.check.position( + symbol=PERP_SYMBOL, expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=expected_remaining_qty, + expected_account_id=perp_taker_tester.account_id, + expected_qty=expected_remaining, expected_side=Side.B, ) - logger.info("✅ Position partial close (long) test completed successfully") - @pytest.mark.asyncio -async def test_position_partial_close_short(reya_tester: ReyaTester): - """Test partially closing a short position""" - symbol = "ETHRUSDPERP" - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - market_price = await reya_tester.data.current_price() +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) + market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) initial_qty = "0.02" - - initial_order = LimitOrderParameters( - symbol=symbol, - is_buy=False, - limit_px=str(float(market_price) * 0.9), - qty=initial_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - - await reya_tester.orders.create_limit(initial_order) - expected_order = limit_order_params_to_order(initial_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_order) - - await reya_tester.check.position( - symbol=symbol, - expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=initial_qty, - expected_side=Side.A, - ) - close_qty = "0.01" - close_order = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.1), - qty=close_qty, - time_in_force=TimeInForce.IOC, - reduce_only=True, - ) - await reya_tester.orders.create_limit(close_order) - expected_close_order = limit_order_params_to_order(close_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_close_order) - - expected_remaining_qty = str(float(initial_qty) - float(close_qty)) - await reya_tester.check.position( - symbol=symbol, - expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=expected_remaining_qty, - expected_side=Side.A, + 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, + ) ) - logger.info("✅ Position partial close (short) test completed successfully") - - -@pytest.mark.asyncio -async def test_position_full_close_with_reduce_only(reya_tester: ReyaTester): - """Test fully closing a position using reduce_only flag""" - symbol = "ETHRUSDPERP" - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - market_price = await reya_tester.data.current_price() - position_qty = "0.01" - - open_order = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.1), - qty=position_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, + 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 reya_tester.orders.create_limit(open_order) - expected_open_order = limit_order_params_to_order(open_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_open_order) - - await reya_tester.check.position( - symbol=symbol, + expected_remaining = str(float(initial_qty) - float(close_qty)) + await perp_taker_tester.check.position( + symbol=PERP_SYMBOL, expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=position_qty, - expected_side=Side.B, - ) - - close_order = LimitOrderParameters( - symbol=symbol, - is_buy=False, - limit_px=str(float(market_price) * 0.9), - qty=position_qty, - time_in_force=TimeInForce.IOC, - reduce_only=True, + expected_account_id=perp_taker_tester.account_id, + expected_qty=expected_remaining, + expected_side=Side.A, ) - await reya_tester.orders.create_limit(close_order) - expected_close_order = limit_order_params_to_order(close_order, reya_tester.account_id) - # Use wait_for_closing_order_execution since position will be fully closed - await reya_tester.wait_for_closing_order_execution(expected_close_order, position_qty) - - await reya_tester.check.position_not_open(symbol) - - logger.info("✅ Position full close with reduce_only test completed successfully") - @pytest.mark.asyncio -async def test_position_decrease_without_reduce_only(reya_tester: ReyaTester): - """Test decreasing a position without reduce_only flag (counter-trade)""" - symbol = "ETHRUSDPERP" - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - market_price = await reya_tester.data.current_price() +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) + market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) initial_qty = "0.02" + counter_qty = "0.01" - open_order = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.1), - qty=initial_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, + 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 reya_tester.orders.create_limit(open_order) - expected_open_order = limit_order_params_to_order(open_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_open_order) + 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 + ) + ) - await reya_tester.check.position( - symbol=symbol, + expected_remaining = str(float(initial_qty) - float(counter_qty)) + await perp_taker_tester.check.position( + symbol=PERP_SYMBOL, expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=initial_qty, + expected_account_id=perp_taker_tester.account_id, + expected_qty=expected_remaining, expected_side=Side.B, ) - counter_qty = "0.01" - counter_order = LimitOrderParameters( - symbol=symbol, - is_buy=False, - limit_px=str(float(market_price) * 0.9), - qty=counter_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) - await reya_tester.orders.create_limit(counter_order) - expected_counter_order = limit_order_params_to_order(counter_order, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_counter_order) +@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) + 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, + ) + ) - expected_remaining_qty = str(float(initial_qty) - float(counter_qty)) - await reya_tester.check.position( - symbol=symbol, - expected_exchange_id=REYA_DEX_ID, - expected_account_id=reya_tester.account_id, - expected_qty=expected_remaining_qty, - expected_side=Side.B, + # 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, + ) ) - logger.info("✅ Position decrease without reduce_only test completed successfully") + await perp_taker_tester.check.position_not_open(PERP_SYMBOL) diff --git a/tests/test_perps/test_trigger_orders.py b/tests/test_perps/test_trigger_orders.py index ca383e49..6ced09ab 100644 --- a/tests/test_perps/test_trigger_orders.py +++ b/tests/test_perps/test_trigger_orders.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 -from typing import Optional +from __future__ import annotations import asyncio @@ -19,6 +19,21 @@ from tests.helpers import ReyaTester from tests.helpers.reya_tester import limit_order_params_to_order, logger, trigger_order_params_to_order +# Trigger orders (TP/SL) currently route through the matching engine as plain +# LIMIT IOCs via the perpOB facade — the ME's dedicated trigger queue isn't +# implemented yet (see `feat/perpOB-7-trigger-queue` planning + comments in +# packages/api/src/controllers/tradingPrivateV2.controller.matching-engine.ts). +# As a result the on-chain trigger-execution semantics these tests assert on +# (price-crossed → fired, position-closed → cancelled, etc.) don't match +# what the system actually does, so every test in this file fails for +# reasons unrelated to the SDK. +# +# todo: p1: remove this module-level skip once the ME trigger queue lands and +# trigger orders execute via their own dispatch path rather than as LIMIT IOCs. +pytestmark = pytest.mark.skip( + reason="ME trigger queue not yet implemented; trigger orders are routed as LIMIT IOCs today (perpOB facade). Re-enable once the dedicated trigger-execution path ships." +) + def assert_tp_sl_order_submission( order_details: Order, @@ -76,8 +91,9 @@ async def test_success_tp_order_create_cancel(reya_tester: ReyaTester): tp_params = TriggerOrderParameters( symbol=symbol, is_buy=False, # on long + qty="0.01", trigger_px=str(float(market_price) * 2), # lower than IOC limit price - trigger_type=OrderType.TP, + trigger_type=OrderType.TAKE_PROFIT, ) tp_order: CreateOrderResponse = await reya_tester.orders.create_trigger(tp_params) logger.info(f"Created TP order with ID: {tp_order.order_id}") @@ -97,7 +113,11 @@ async def test_success_tp_order_create_cancel(reya_tester: ReyaTester): ) # CANCEL order - await reya_tester.client.cancel_order(order_id=active_tp_order.order_id) + await reya_tester.client.cancel_order( + symbol=symbol, + account_id=reya_tester.account_id, + order_id=active_tp_order.order_id, + ) await reya_tester.wait.for_order_state(active_tp_order.order_id, OrderStatus.CANCELLED) await reya_tester.check_no_order_execution_since(sequence_after_position) @@ -139,8 +159,9 @@ async def test_success_sl_order_create_cancel(reya_tester: ReyaTester): sl_params = TriggerOrderParameters( symbol=symbol, is_buy=False, # on long + qty="0.01", trigger_px=str(float(market_price) * 0.9), # higher than IOC limit price - trigger_type=OrderType.SL, + trigger_type=OrderType.STOP_LOSS, ) order_response = await reya_tester.orders.create_trigger(sl_params) logger.info(f"Created SL order with ID: {order_response.order_id}") @@ -160,7 +181,11 @@ async def test_success_sl_order_create_cancel(reya_tester: ReyaTester): ) # CANCEL - await reya_tester.client.cancel_order(order_id=active_sl_order.order_id) + await reya_tester.client.cancel_order( + symbol=symbol, + account_id=reya_tester.account_id, + order_id=active_sl_order.order_id, + ) await reya_tester.wait.for_order_state(active_sl_order.order_id, OrderStatus.CANCELLED) await reya_tester.check_no_order_execution_since(sequence_after_position) await reya_tester.check.position( @@ -179,14 +204,18 @@ async def test_success_sl_order_create_cancel(reya_tester: ReyaTester): CO_TIMEOUT_PER_ATTEMPT = 30 -async def _cancel_order_if_open(reya_tester: ReyaTester, order_id: Optional[str]) -> None: +async def _cancel_order_if_open(reya_tester: ReyaTester, order_id: str | None, symbol: str = "ETHRUSDPERP") -> None: """Cancel an order if it's still open. Silently ignores errors.""" if order_id is None: return try: ws_order = reya_tester.ws.orders.get(str(order_id)) if ws_order and ws_order.status.value == "OPEN": - await reya_tester.client.cancel_order(order_id=order_id) + await reya_tester.client.cancel_order( + symbol=symbol, + account_id=reya_tester.account_id, + order_id=order_id, + ) await reya_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED, timeout=10) except (ApiException, OSError, RuntimeError, asyncio.TimeoutError): pass # Intentionally ignore errors during cleanup - order may already be cancelled/filled @@ -242,8 +271,9 @@ async def test_tp_in_cross_executes_immediately(reya_tester: ReyaTester): tp_params = TriggerOrderParameters( symbol=symbol, is_buy=True, + qty="0.01", trigger_px=str(float(market_price) * 1.1), - trigger_type=OrderType.TP, + trigger_type=OrderType.TAKE_PROFIT, ) tp_order = await reya_tester.orders.create_trigger(tp_params) logger.info(f"Created TP order: {tp_order.order_id}") @@ -321,8 +351,9 @@ async def test_sl_in_cross_executes_immediately(reya_tester: ReyaTester): sl_params = TriggerOrderParameters( symbol=symbol, is_buy=True, + qty="0.01", trigger_px=str(float(market_price) * 0.9), - trigger_type=OrderType.SL, + trigger_type=OrderType.STOP_LOSS, ) sl_order = await reya_tester.orders.create_trigger(sl_params) logger.info(f"Created SL order: {sl_order.order_id}") @@ -369,8 +400,9 @@ async def test_failure_sltp_when_no_position(reya_tester: ReyaTester): sl_params = TriggerOrderParameters( symbol=symbol, is_buy=False, # on short position + qty="0.01", trigger_px=str(float(market_price) * 0.9), # in the money - trigger_type=OrderType.SL, + trigger_type=OrderType.STOP_LOSS, ) order_response_sl: CreateOrderResponse = await reya_tester.orders.create_trigger(sl_params) # ENSURE IT WAS NOT FILLED NOR STILL OPENED @@ -386,8 +418,9 @@ async def test_failure_sltp_when_no_position(reya_tester: ReyaTester): tp_params = TriggerOrderParameters( symbol=symbol, is_buy=False, # on short position + qty="0.01", trigger_px=str(float(market_price) * 0.9), # in the money - trigger_type=OrderType.TP, + trigger_type=OrderType.TAKE_PROFIT, ) order_response_tp: CreateOrderResponse = await reya_tester.orders.create_trigger(tp_params) # ENSURE IT WAS NOT FILLED NOR STILL OPENED @@ -411,7 +444,11 @@ async def test_failure_cancel_when_order_is_not_found(reya_tester: ReyaTester): """ await reya_tester.check.no_open_orders() try: - await reya_tester.client.cancel_order(order_id="unknown_id") + await reya_tester.client.cancel_order( + symbol="ETHRUSDPERP", + account_id=reya_tester.account_id, + order_id="unknown_id", + ) raise RuntimeError("Should have failed") except BadRequestException as e: assert e.data is not None @@ -471,8 +508,9 @@ async def test_sltp_cancelled_when_position_closed(reya_tester: ReyaTester): sl_params = TriggerOrderParameters( symbol=symbol, is_buy=False, + qty="0.01", trigger_px=str(float(market_price) * 0.95), - trigger_type=OrderType.SL, + trigger_type=OrderType.STOP_LOSS, ) sl_response = await reya_tester.orders.create_trigger(sl_params) logger.info(f"Created SL order: {sl_response.order_id}") @@ -481,8 +519,9 @@ async def test_sltp_cancelled_when_position_closed(reya_tester: ReyaTester): tp_params = TriggerOrderParameters( symbol=symbol, is_buy=False, + qty="0.01", trigger_px=str(float(market_price) * 1.05), - trigger_type=OrderType.TP, + trigger_type=OrderType.TAKE_PROFIT, ) tp_response = await reya_tester.orders.create_trigger(tp_params) logger.info(f"Created TP order: {tp_response.order_id}") @@ -559,8 +598,9 @@ async def test_sltp_cancelled_when_position_flipped(reya_tester: ReyaTester): sl_params = TriggerOrderParameters( symbol=symbol, is_buy=False, + qty="0.01", trigger_px=str(float(market_price) * 0.95), - trigger_type=OrderType.SL, + trigger_type=OrderType.STOP_LOSS, ) sl_response = await reya_tester.orders.create_trigger(sl_params) logger.info(f"Created SL order: {sl_response.order_id}") @@ -569,8 +609,9 @@ async def test_sltp_cancelled_when_position_flipped(reya_tester: ReyaTester): tp_params = TriggerOrderParameters( symbol=symbol, is_buy=False, + qty="0.01", trigger_px=str(float(market_price) * 1.05), - trigger_type=OrderType.TP, + trigger_type=OrderType.TAKE_PROFIT, ) tp_response = await reya_tester.orders.create_trigger(tp_params) logger.info(f"Created TP order: {tp_response.order_id}") @@ -657,8 +698,9 @@ async def test_sl_execution_cancels_tp(reya_tester: ReyaTester): sl_params = TriggerOrderParameters( symbol=symbol, is_buy=False, + qty="0.01", trigger_px=str(float(market_price) * 1.01), - trigger_type=OrderType.SL, + trigger_type=OrderType.STOP_LOSS, ) sl_order = await reya_tester.orders.create_trigger(sl_params) logger.info(f"Created SL order: {sl_order.order_id}") @@ -667,8 +709,9 @@ async def test_sl_execution_cancels_tp(reya_tester: ReyaTester): tp_params = TriggerOrderParameters( symbol=symbol, is_buy=False, + qty="0.01", trigger_px=str(float(market_price) * 1.10), - trigger_type=OrderType.TP, + trigger_type=OrderType.TAKE_PROFIT, ) tp_order = await reya_tester.orders.create_trigger(tp_params) logger.info(f"Created TP order: {tp_order.order_id}") @@ -746,8 +789,9 @@ async def test_tp_execution_cancels_sl(reya_tester: ReyaTester): sl_params = TriggerOrderParameters( symbol=symbol, is_buy=False, + qty="0.01", trigger_px=str(float(market_price) * 0.90), - trigger_type=OrderType.SL, + trigger_type=OrderType.STOP_LOSS, ) sl_order = await reya_tester.orders.create_trigger(sl_params) logger.info(f"Created SL order: {sl_order.order_id}") @@ -756,8 +800,9 @@ async def test_tp_execution_cancels_sl(reya_tester: ReyaTester): tp_params = TriggerOrderParameters( symbol=symbol, is_buy=False, + qty="0.01", trigger_px=str(float(market_price) * 0.99), - trigger_type=OrderType.TP, + trigger_type=OrderType.TAKE_PROFIT, ) tp_order = await reya_tester.orders.create_trigger(tp_params) logger.info(f"Created TP order: {tp_order.order_id}") diff --git a/tests/test_perps/test_wallet_data.py b/tests/test_perps/test_wallet_data.py index 484f5778..44dc01ae 100644 --- a/tests/test_perps/test_wallet_data.py +++ b/tests/test_perps/test_wallet_data.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """Tests for wallet-related perp API endpoints (positions, perp executions, accounts, balances). -Note: WS+REST consistency verification is handled centrally by wait_for_order_execution() -in the waiters module, which checks both REST and WebSocket for executions and positions. +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``. """ import pytest @@ -12,7 +13,44 @@ 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.reya_tester import limit_order_params_to_order, logger +from tests.helpers.liquidity_detector import skip_if_external_liquidity +from tests.helpers.reya_tester import logger + +PERP_SYMBOL = "ETHRUSDPERP" +PERP_QTY = "0.01" + + +async def _rest_maker_sell(maker: ReyaTester, market_price: float, qty: str = PERP_QTY) -> str: + """Place a maker sell at 1% below oracle so a taker BUY @ 1.05x oracle crosses it. + + Skips the calling test if any external liquidity is on the ETHRUSDPERP book + within the ±5% circuit-breaker band: at oracle − 1% the maker sell would + 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``. + """ + await skip_if_external_liquidity( + maker.data, + PERP_SYMBOL, + market_price, + reason_prefix="_rest_maker_sell", + ) + + price = str(round(market_price * 0.99, 2)) + order_id = await maker.orders.create_limit( + LimitOrderParameters( + symbol=PERP_SYMBOL, + is_buy=False, + limit_px=price, + qty=qty, + time_in_force=TimeInForce.GTC, + ) + ) + assert order_id is not None + await maker.wait.for_order_creation(order_id=order_id) + return order_id @pytest.mark.asyncio @@ -31,79 +69,83 @@ async def test_get_wallet_positions_empty(reya_tester: ReyaTester): @pytest.mark.asyncio -async def test_get_wallet_positions_with_position(reya_tester: ReyaTester): - """Test getting positions when a position exists""" - symbol = "ETHRUSDPERP" - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - market_price = await reya_tester.data.current_price() - test_qty = "0.01" - - limit_order_params = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.1), - qty=test_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, +async def test_get_wallet_positions_with_position(perp_maker_tester: ReyaTester, perp_taker_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. + """ + 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( + 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 reya_tester.orders.create_limit(limit_order_params) - - expected_order = limit_order_params_to_order(limit_order_params, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_order) - - positions = await reya_tester.data.positions() + positions = await perp_taker_tester.data.positions() assert isinstance(positions, dict), "Positions should be a dictionary" - assert symbol in positions, f"Should have position for {symbol}" + assert PERP_SYMBOL in positions, f"Should have position for {PERP_SYMBOL}" - position = positions[symbol] - assert position.symbol == symbol, "Position symbol should match" - assert position.account_id == reya_tester.account_id, "Position account ID should match" + 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(test_qty), "Position qty should match" + assert float(position.qty) == float(PERP_QTY), "Position qty should match" assert position.side == Side.B, "Position side should be BUY" logger.info("✅ Wallet positions (with position) test completed successfully") @pytest.mark.asyncio -async def test_get_wallet_perp_executions(reya_tester: ReyaTester): - """Test getting perp execution history for wallet""" - symbol = "ETHRUSDPERP" +async def test_get_wallet_perp_executions(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester): + """Latest wallet perp execution reflects the most recent fill. - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) + Maker rests a GTC sell, taker crosses with IOC buy. Asserts the taker + wallet's latest execution matches the fill. + """ + await perp_taker_tester.check.position_not_open(PERP_SYMBOL) - last_execution_before = await reya_tester.get_last_wallet_perp_execution() + 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 - market_price = await reya_tester.data.current_price() - test_qty = "0.01" - - limit_order_params = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.1), - qty=test_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, - ) + market_price = float(await perp_taker_tester.data.current_price(PERP_SYMBOL)) - await reya_tester.orders.create_limit(limit_order_params) + await _rest_maker_sell(perp_maker_tester, market_price) - expected_order = limit_order_params_to_order(limit_order_params, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_order) + 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, + ) + ) - last_execution_after = await reya_tester.get_last_wallet_perp_execution() + last_execution_after = await perp_taker_tester.get_last_wallet_perp_execution() 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 == symbol, "Execution symbol should match" - assert last_execution_after.account_id == reya_tester.account_id, "Execution account ID should match" + assert last_execution_after.symbol == PERP_SYMBOL, "Execution symbol should match" + # PerpExecution uses takerAccountId / makerAccountId — the wallet whose + # endpoint we queried is the taker, since the taker placed the IOC. + assert ( + last_execution_after.taker_account_id == perp_taker_tester.account_id + ), "Execution taker account ID should match the taker tester" + assert ( + last_execution_after.maker_account_id == perp_maker_tester.account_id + ), "Execution maker account ID should match the maker tester" assert last_execution_after.exchange_id == REYA_DEX_ID, "Execution exchange ID should match" - assert float(last_execution_after.qty) == float(test_qty), "Execution qty should match" + assert float(last_execution_after.qty) == float(PERP_QTY), "Execution qty should match" assert last_execution_after.side == Side.B, "Execution side should be BUY" logger.info("✅ Wallet perp executions test completed successfully") @@ -163,36 +205,35 @@ async def test_get_wallet_configuration(reya_tester: ReyaTester): @pytest.mark.asyncio -async def test_get_single_position(reya_tester: ReyaTester): - """Test getting a single position by symbol""" - symbol = "ETHRUSDPERP" - - await reya_tester.check.no_open_orders() - await reya_tester.check.position_not_open(symbol) - - position = await reya_tester.data.position(symbol) - assert position is None, "Should not have position initially" - - market_price = await reya_tester.data.current_price() - test_qty = "0.01" - - limit_order_params = LimitOrderParameters( - symbol=symbol, - is_buy=True, - limit_px=str(float(market_price) * 1.1), - qty=test_qty, - time_in_force=TimeInForce.IOC, - reduce_only=False, +async def test_get_single_position(perp_maker_tester: ReyaTester, perp_taker_tester: ReyaTester): + """Single-position lookup by symbol returns the freshly-formed position. + + Maker rests a GTC sell, taker crosses with IOC buy. Asserts the + single-position lookup on the taker reflects the fill. + """ + 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" + + 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( + 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 reya_tester.orders.create_limit(limit_order_params) - - expected_order = limit_order_params_to_order(limit_order_params, reya_tester.account_id) - await reya_tester.wait.for_order_execution(expected_order) - - position = await reya_tester.data.position(symbol) + position = await perp_taker_tester.data.position(PERP_SYMBOL) assert position is not None, "Should have position after order" - assert position.symbol == symbol, "Position symbol should match" - assert float(position.qty) == float(test_qty), "Position qty should match" + assert position.symbol == PERP_SYMBOL, "Position symbol should match" + assert float(position.qty) == float(PERP_QTY), "Position qty should match" logger.info("✅ Get single position test completed successfully") diff --git a/tests/test_spot/test_api_validation.py b/tests/test_spot/test_api_validation.py index 3e756e12..fb7955dc 100644 --- a/tests/test_spot/test_api_validation.py +++ b/tests/test_spot/test_api_validation.py @@ -8,8 +8,7 @@ - Balance validity (IOC orders) - Price/Qty step size validity -High and Medium priority validation tests for spot market orders. -""" +High and Medium priority validation tests for spot market orders.""" import asyncio import time @@ -68,7 +67,11 @@ async def test_spot_order_invalid_signature(spot_config: SpotTestConfig, spot_te qty=spot_config.min_qty, orderType=OrderType.LIMIT, timeInForce=TimeInForce.GTC, - expiresAfter=deadline, + deadline=deadline, + # GTC orders carry no lifetime: expiresAfter must be 0 (only GTT + # sets it). A non-zero value is rejected at request validation, + # before the signature path this test exercises. + expiresAfter=0, reduceOnly=None, signature=fake_signature, nonce=str(nonce), @@ -127,21 +130,24 @@ async def test_spot_order_wrong_signer(spot_config: SpotTestConfig, spot_tester: nonce = spot_tester.get_next_nonce() # Sign with the wrong private key - inputs = wrong_signer.encode_inputs_limit_order( - is_buy=True, - limit_px=Decimal(str(order_price)), - qty=Decimal(spot_config.min_qty), - ) - - signature = wrong_signer.sign_raw_order( - account_id=spot_tester.account_id, # Same account + signature = wrong_signer.sign_order( + account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, # LIMIT_ORDER_SPOT - inputs=inputs, - deadline=deadline, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(str(order_price)))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + # Match the request's `expiresAfter` (0 for GTC) so the signature + # recovers against the same envelope the server validates — else + # we'd fail signature recovery, not the permission check under test. + expires_after=0, nonce=nonce, + deadline=deadline, ) order_request = CreateOrderRequest( @@ -153,7 +159,10 @@ async def test_spot_order_wrong_signer(spot_config: SpotTestConfig, spot_tester: qty=spot_config.min_qty, orderType=OrderType.LIMIT, timeInForce=TimeInForce.GTC, - expiresAfter=deadline, + deadline=deadline, + # GTC carries no lifetime: expiresAfter must be 0 (only GTT sets it), + # else request validation rejects before the permission check. + expiresAfter=0, reduceOnly=None, signature=signature, nonce=str(nonce), @@ -209,21 +218,21 @@ async def test_spot_order_expired_deadline(spot_config: SpotTestConfig, spot_tes # Get signature generator from client sig_gen = spot_tester.client.signature_generator - inputs = sig_gen.encode_inputs_limit_order( - is_buy=True, - limit_px=Decimal(str(order_price)), - qty=Decimal(spot_config.min_qty), - ) - - signature = sig_gen.sign_raw_order( + signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, # LIMIT_ORDER_SPOT - inputs=inputs, - deadline=expired_deadline, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(str(order_price)))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, nonce=nonce, + deadline=expired_deadline, ) order_request = CreateOrderRequest( @@ -235,7 +244,7 @@ async def test_spot_order_expired_deadline(spot_config: SpotTestConfig, spot_tes qty=spot_config.min_qty, orderType=OrderType.LIMIT, timeInForce=TimeInForce.GTC, - expiresAfter=expired_deadline, + deadline=expired_deadline, reduceOnly=None, signature=signature, nonce=str(nonce), @@ -294,7 +303,7 @@ async def test_spot_cancel_expired_deadline(spot_config: SpotTestConfig, spot_te nonce = spot_tester.get_next_nonce() sig_gen = spot_tester.client.signature_generator - signature = sig_gen.sign_cancel_order_spot( + signature = sig_gen.sign_cancel_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, order_id=int(order_id), @@ -309,7 +318,7 @@ async def test_spot_cancel_expired_deadline(spot_config: SpotTestConfig, spot_te accountId=spot_tester.account_id, signature=signature, nonce=str(nonce), - expiresAfter=expired_deadline, + deadline=expired_deadline, ) logger.info(f"Sending cancel with expired deadline: {expired_deadline}") @@ -366,21 +375,21 @@ async def test_spot_order_reused_nonce(spot_config: SpotTestConfig, spot_tester: sig_gen = spot_tester.client.signature_generator - inputs = sig_gen.encode_inputs_limit_order( - is_buy=True, - limit_px=Decimal(str(order_price)), - qty=Decimal(spot_config.min_qty), - ) - - first_signature = sig_gen.sign_raw_order( + first_signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, - deadline=first_deadline, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(str(order_price)))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, # GTC has no lifetime; matches the wire expiresAfter (0) nonce=first_nonce, + deadline=first_deadline, ) first_order_request = CreateOrderRequest( @@ -392,7 +401,8 @@ async def test_spot_order_reused_nonce(spot_config: SpotTestConfig, spot_tester: qty=spot_config.min_qty, orderType=OrderType.LIMIT, timeInForce=TimeInForce.GTC, - expiresAfter=first_deadline, + deadline=first_deadline, + expiresAfter=0, reduceOnly=None, signature=first_signature, nonce=str(first_nonce), @@ -414,15 +424,21 @@ async def test_spot_order_reused_nonce(spot_config: SpotTestConfig, spot_tester: # Step 2: Try to reuse the same nonce - should fail reused_deadline = int(time.time()) + 60 # 1 minute from now (in seconds) - reused_signature = sig_gen.sign_raw_order( + reused_signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(str(order_price)))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, # GTC has no lifetime; matches the wire expiresAfter (0) + nonce=first_nonce, deadline=reused_deadline, - nonce=first_nonce, # Reuse the same nonce ) reused_order_request = CreateOrderRequest( @@ -434,7 +450,8 @@ async def test_spot_order_reused_nonce(spot_config: SpotTestConfig, spot_tester: qty=spot_config.min_qty, orderType=OrderType.LIMIT, timeInForce=TimeInForce.GTC, - expiresAfter=reused_deadline, + deadline=reused_deadline, + expiresAfter=0, reduceOnly=None, signature=reused_signature, nonce=str(first_nonce), # Reuse the same nonce @@ -483,21 +500,21 @@ async def test_spot_order_old_nonce(spot_config: SpotTestConfig, spot_tester: Re sig_gen = spot_tester.client.signature_generator - inputs = sig_gen.encode_inputs_limit_order( - is_buy=True, - limit_px=Decimal(str(order_price)), - qty=Decimal(spot_config.min_qty), - ) - - first_signature = sig_gen.sign_raw_order( + first_signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, - deadline=first_deadline, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(str(order_price)))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, # GTC has no lifetime; matches the wire expiresAfter (0) nonce=first_nonce, + deadline=first_deadline, ) first_order_request = CreateOrderRequest( @@ -509,7 +526,8 @@ async def test_spot_order_old_nonce(spot_config: SpotTestConfig, spot_tester: Re qty=spot_config.min_qty, orderType=OrderType.LIMIT, timeInForce=TimeInForce.GTC, - expiresAfter=first_deadline, + deadline=first_deadline, + expiresAfter=0, reduceOnly=None, signature=first_signature, nonce=str(first_nonce), @@ -532,15 +550,21 @@ async def test_spot_order_old_nonce(spot_config: SpotTestConfig, spot_tester: Re old_nonce = first_nonce - 1 old_deadline = int(time.time()) + 60 # 1 minute from now (in seconds) - old_signature = sig_gen.sign_raw_order( + old_signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(str(order_price)))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, # GTC has no lifetime; matches the wire expiresAfter (0) + nonce=old_nonce, deadline=old_deadline, - nonce=old_nonce, # Use nonce - 1 ) old_order_request = CreateOrderRequest( @@ -552,7 +576,8 @@ async def test_spot_order_old_nonce(spot_config: SpotTestConfig, spot_tester: Re qty=spot_config.min_qty, orderType=OrderType.LIMIT, timeInForce=TimeInForce.GTC, - expiresAfter=old_deadline, + deadline=old_deadline, + expiresAfter=0, reduceOnly=None, signature=old_signature, nonce=str(old_nonce), # Use nonce - 1 @@ -608,6 +633,7 @@ async def test_spot_ioc_insufficient_balance_buy(spot_config: SpotTestConfig, sp 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}") @@ -668,6 +694,7 @@ async def test_spot_ioc_insufficient_balance_sell(spot_config: SpotTestConfig, s 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}") @@ -899,7 +926,7 @@ async def test_spot_cancel_invalid_signature(spot_config: SpotTestConfig, spot_t accountId=spot_tester.account_id, signature=fake_signature, nonce=str(nonce), - expiresAfter=deadline, + deadline=deadline, ) logger.info("Sending cancel with invalid signature...") @@ -964,7 +991,7 @@ async def test_spot_cancel_reused_nonce(spot_config: SpotTestConfig, spot_tester first_deadline = int(time.time()) + 60 # 1 minute from now (in seconds) sig_gen = spot_tester.client.signature_generator - first_signature = sig_gen.sign_cancel_order_spot( + first_signature = sig_gen.sign_cancel_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, order_id=int(first_order_id), @@ -979,7 +1006,7 @@ async def test_spot_cancel_reused_nonce(spot_config: SpotTestConfig, spot_tester accountId=spot_tester.account_id, signature=first_signature, nonce=str(first_nonce), - expiresAfter=first_deadline, + deadline=first_deadline, ) logger.info(f"Step 1: Cancelling first order with nonce: {first_nonce}") @@ -994,7 +1021,7 @@ async def test_spot_cancel_reused_nonce(spot_config: SpotTestConfig, spot_tester logger.info(f"Step 2: Created second order: {second_order_id}") reused_deadline = int(time.time()) + 60 # 1 minute from now (in seconds) - reused_signature = sig_gen.sign_cancel_order_spot( + reused_signature = sig_gen.sign_cancel_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, order_id=int(second_order_id), @@ -1009,7 +1036,7 @@ async def test_spot_cancel_reused_nonce(spot_config: SpotTestConfig, spot_tester accountId=spot_tester.account_id, signature=reused_signature, nonce=str(first_nonce), # Reuse the same nonce - expiresAfter=reused_deadline, + deadline=reused_deadline, ) logger.info(f"Step 2: Trying to cancel with reused nonce: {first_nonce}") @@ -1073,7 +1100,7 @@ async def test_spot_cancel_old_nonce(spot_config: SpotTestConfig, spot_tester: R first_deadline = int(time.time()) + 60 # 1 minute from now (in seconds) sig_gen = spot_tester.client.signature_generator - first_signature = sig_gen.sign_cancel_order_spot( + first_signature = sig_gen.sign_cancel_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, order_id=int(first_order_id), @@ -1088,7 +1115,7 @@ async def test_spot_cancel_old_nonce(spot_config: SpotTestConfig, spot_tester: R accountId=spot_tester.account_id, signature=first_signature, nonce=str(first_nonce), - expiresAfter=first_deadline, + deadline=first_deadline, ) logger.info(f"Step 1: Cancelling first order with nonce: {first_nonce}") @@ -1104,7 +1131,7 @@ async def test_spot_cancel_old_nonce(spot_config: SpotTestConfig, spot_tester: R old_nonce = first_nonce - 1 old_deadline = int(time.time()) + 60 # 1 minute from now (in seconds) - old_signature = sig_gen.sign_cancel_order_spot( + old_signature = sig_gen.sign_cancel_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, order_id=int(second_order_id), @@ -1119,7 +1146,7 @@ async def test_spot_cancel_old_nonce(spot_config: SpotTestConfig, spot_tester: R accountId=spot_tester.account_id, signature=old_signature, nonce=str(old_nonce), # Use nonce - 1 - expiresAfter=old_deadline, + deadline=old_deadline, ) logger.info(f"Step 2: Trying to cancel with old nonce (nonce-1): {old_nonce}") @@ -1175,7 +1202,7 @@ async def test_spot_mass_cancel_invalid_signature(spot_config: SpotTestConfig, s symbol=spot_config.symbol, signature=fake_signature, nonce=str(nonce), - expiresAfter=deadline, + deadline=deadline, ) logger.info("Sending mass cancel with invalid signature...") @@ -1224,7 +1251,7 @@ async def test_spot_mass_cancel_expired_deadline(spot_config: SpotTestConfig, sp symbol=spot_config.symbol, signature=signature, nonce=str(nonce), - expiresAfter=expired_deadline, + deadline=expired_deadline, ) logger.info(f"Sending mass cancel with expired deadline: {expired_deadline}") @@ -1279,7 +1306,7 @@ async def test_spot_mass_cancel_reused_nonce(spot_config: SpotTestConfig, spot_t symbol=spot_config.symbol, signature=first_signature, nonce=str(first_nonce), - expiresAfter=first_deadline, + deadline=first_deadline, ) logger.info(f"Step 1: Performing mass cancel with nonce: {first_nonce}") @@ -1300,7 +1327,7 @@ async def test_spot_mass_cancel_reused_nonce(spot_config: SpotTestConfig, spot_t symbol=spot_config.symbol, signature=reused_signature, nonce=str(first_nonce), # Reuse the same nonce - expiresAfter=reused_deadline, + deadline=reused_deadline, ) logger.info(f"Step 2: Trying mass cancel with reused nonce: {first_nonce}") @@ -1355,7 +1382,7 @@ async def test_spot_mass_cancel_old_nonce(spot_config: SpotTestConfig, spot_test symbol=spot_config.symbol, signature=first_signature, nonce=str(first_nonce), - expiresAfter=first_deadline, + deadline=first_deadline, ) logger.info(f"Step 1: Performing mass cancel with nonce: {first_nonce}") @@ -1377,7 +1404,7 @@ async def test_spot_mass_cancel_old_nonce(spot_config: SpotTestConfig, spot_test symbol=spot_config.symbol, signature=old_signature, nonce=str(old_nonce), - expiresAfter=old_deadline, + deadline=old_deadline, ) logger.info(f"Step 2: Trying mass cancel with old nonce (nonce-1): {old_nonce}") @@ -1445,7 +1472,7 @@ async def test_spot_cancel_wrong_signer(spot_config: SpotTestConfig, spot_tester nonce = spot_tester.get_next_nonce() # Sign cancel request with the wrong private key - wrong_signature = wrong_signer.sign_cancel_order_spot( + wrong_signature = wrong_signer.sign_cancel_order( account_id=spot_tester.account_id, # Same account market_id=spot_config.market_id, order_id=int(order_id), @@ -1460,7 +1487,7 @@ async def test_spot_cancel_wrong_signer(spot_config: SpotTestConfig, spot_tester accountId=spot_tester.account_id, signature=wrong_signature, nonce=str(nonce), - expiresAfter=deadline, + deadline=deadline, ) logger.info(f"Sending cancel signed by wrong wallet: {wrong_signer.signer_wallet_address}") @@ -1546,7 +1573,7 @@ async def test_spot_mass_cancel_wrong_signer(spot_config: SpotTestConfig, spot_t symbol=spot_config.symbol, signature=wrong_signature, nonce=str(nonce), - expiresAfter=deadline, + deadline=deadline, ) logger.info(f"Sending mass cancel signed by wrong wallet: {wrong_signer.signer_wallet_address}") @@ -1602,21 +1629,21 @@ async def test_spot_order_invalid_exchange_id(spot_config: SpotTestConfig, spot_ sig_gen = spot_tester.client.signature_generator # Create inputs for signing - inputs = sig_gen.encode_inputs_limit_order( - is_buy=True, - limit_px=Decimal(price), - qty=Decimal(spot_config.min_qty), - ) - - signature = sig_gen.sign_raw_order( + signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, - deadline=deadline, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(price))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, nonce=nonce, + deadline=deadline, ) # Create request with invalid exchangeId (0 or negative) @@ -1631,7 +1658,7 @@ async def test_spot_order_invalid_exchange_id(spot_config: SpotTestConfig, spot_ timeInForce=TimeInForce.GTC, signature=signature, nonce=str(nonce), - expiresAfter=deadline, + deadline=deadline, signerWallet=sig_gen.signer_wallet_address, ) @@ -1672,21 +1699,21 @@ async def test_spot_order_invalid_symbol(spot_config: SpotTestConfig, spot_teste sig_gen = spot_tester.client.signature_generator - inputs = sig_gen.encode_inputs_limit_order( - is_buy=True, - limit_px=Decimal(price), - qty=Decimal(spot_config.min_qty), - ) - - signature = sig_gen.sign_raw_order( + signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, - deadline=deadline, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(price))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, nonce=nonce, + deadline=deadline, ) # Create request with invalid symbol @@ -1701,7 +1728,7 @@ async def test_spot_order_invalid_symbol(spot_config: SpotTestConfig, spot_teste timeInForce=TimeInForce.GTC, signature=signature, nonce=str(nonce), - expiresAfter=deadline, + deadline=deadline, signerWallet=sig_gen.signer_wallet_address, ) @@ -1754,7 +1781,7 @@ async def test_spot_order_missing_signature(spot_config: SpotTestConfig, spot_te timeInForce=TimeInForce.GTC, signature="", # Empty signature nonce=str(nonce), - expiresAfter=deadline, + deadline=deadline, signerWallet=sig_gen.signer_wallet_address, ) @@ -1795,21 +1822,21 @@ async def test_spot_order_missing_nonce(spot_config: SpotTestConfig, spot_tester sig_gen = spot_tester.client.signature_generator - inputs = sig_gen.encode_inputs_limit_order( - is_buy=True, - limit_px=Decimal(price), - qty=Decimal(spot_config.min_qty), - ) - - signature = sig_gen.sign_raw_order( + signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, - deadline=deadline, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(price))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, # GTC has no lifetime; matches the wire expiresAfter (0) nonce=nonce, + deadline=deadline, ) # Create request without nonce (empty string) @@ -1824,7 +1851,8 @@ async def test_spot_order_missing_nonce(spot_config: SpotTestConfig, spot_tester timeInForce=TimeInForce.GTC, signature=signature, nonce="", # Empty nonce - expiresAfter=deadline, + deadline=deadline, + expiresAfter=0, signerWallet=sig_gen.signer_wallet_address, ) @@ -1865,21 +1893,21 @@ async def test_spot_order_invalid_time_in_force(spot_config: SpotTestConfig, spo sig_gen = spot_tester.client.signature_generator - inputs = sig_gen.encode_inputs_limit_order( - is_buy=True, - limit_px=Decimal(price), - qty=Decimal(spot_config.min_qty), - ) - - signature = sig_gen.sign_raw_order( + signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, - deadline=deadline, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(price))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, nonce=nonce, + deadline=deadline, ) # Create request dict with invalid timeInForce (bypass enum validation) @@ -1894,7 +1922,7 @@ async def test_spot_order_invalid_time_in_force(spot_config: SpotTestConfig, spo "timeInForce": "INVALID_TIF", # Invalid value "signature": signature, "nonce": str(nonce), - "expiresAfter": deadline, + "deadline": deadline, "signerWallet": sig_gen.signer_wallet_address, } @@ -1924,11 +1952,11 @@ async def test_spot_order_invalid_time_in_force(spot_config: SpotTestConfig, spo @pytest.mark.spot @pytest.mark.validation @pytest.mark.asyncio -async def test_spot_order_missing_expiration(spot_config: SpotTestConfig, spot_tester: ReyaTester): +async def test_spot_order_missing_deadline(spot_config: SpotTestConfig, spot_tester: ReyaTester): """ - Test that a spot order without expiresAfter is rejected. + Test that a spot order without deadline is rejected. - The API should validate that expiresAfter is required for spot orders. + The API should validate that deadline is required for orders under v2.3.0. """ logger.info("=" * 80) logger.info("SPOT ORDER MISSING EXPIRATION TEST") @@ -1942,24 +1970,24 @@ async def test_spot_order_missing_expiration(spot_config: SpotTestConfig, spot_t sig_gen = spot_tester.client.signature_generator - inputs = sig_gen.encode_inputs_limit_order( - is_buy=True, - limit_px=Decimal(price), - qty=Decimal(spot_config.min_qty), - ) - - signature = sig_gen.sign_raw_order( + signature = sig_gen.sign_order( account_id=spot_tester.account_id, market_id=spot_config.market_id, exchange_id=spot_tester.client.config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, - deadline=deadline, + order_type=0, # LIMIT + is_buy=True, + qty=Decimal(str(Decimal(spot_config.min_qty))), + limit_price=Decimal(str(Decimal(price))), + trigger_price=Decimal(0), + time_in_force=0, # GTC + client_order_id=0, + reduce_only=False, + expires_after=0, nonce=nonce, + deadline=deadline, ) - # Create request dict without expiresAfter + # Create request dict without deadline order_dict = { "exchangeId": spot_tester.client.config.dex_id, "symbol": spot_config.symbol, @@ -1971,22 +1999,22 @@ async def test_spot_order_missing_expiration(spot_config: SpotTestConfig, spot_t "timeInForce": "GTC", "signature": signature, "nonce": str(nonce), - # expiresAfter intentionally omitted + # deadline intentionally omitted "signerWallet": sig_gen.signer_wallet_address, } - logger.info("Sending order without expiresAfter...") + logger.info("Sending order without deadline...") try: async with aiohttp.ClientSession() as session: url = f"{spot_tester.client.config.api_url}/createOrder" async with session.post(url, json=order_dict) as resp: if resp.status == 200: - pytest.fail("Order without expiresAfter should have been rejected") + pytest.fail("Order without deadline should have been rejected") response_text = await resp.text() assert ( - "expiresAfter" in response_text.lower() or "expires" in response_text.lower() or resp.status == 400 - ), f"Expected expiresAfter validation error, got: {response_text}" + "deadline" in response_text.lower() or "expires" in response_text.lower() or resp.status == 400 + ), f"Expected deadline validation error, got: {response_text}" logger.info(f"✅ Order rejected as expected: HTTP {resp.status}") logger.info(f" Error: {response_text[:150]}") except ApiException as e: diff --git a/tests/test_spot/test_error_handling.py b/tests/test_spot/test_error_handling.py index 7e2d0a98..60b030b1 100644 --- a/tests/test_spot/test_error_handling.py +++ b/tests/test_spot/test_error_handling.py @@ -246,7 +246,16 @@ async def test_spot_extreme_price(spot_config: SpotTestConfig, spot_tester: Reya await spot_tester.orders.close_all(fail_if_none=False) - # Extremely high price + # Extremely high price (~1e12), well above the ME's max price bound + # (MAX_QTY_OR_PX ≈ 1.84e10), so the order is rejected; this test only + # asserts it's handled gracefully (any ApiException) and leaves no resting + # order. NOTE: server-side the spot balance check runs *before* px/qty + # validation, so the rejection surfaces as "Insufficient balance" + # (qty × 1e12 ≈ 1e9 > balance) rather than the truer "price exceeds maximum" + # — which is why this test shows up as ~1e9 "Insufficient balance for spot + # order" warns in devnet logs (not a bug; see Linear PRO-160). To be very + # clean the server should validate px/qty before the balance check — + # tracked low-priority in reya-off-chain-monorepo#2669. extreme_price = "999999999999" order_params = OrderBuilder.from_config(spot_config).buy().price(extreme_price).gtc().build() diff --git a/tests/test_spot/test_spot_execution_busts.py b/tests/test_spot/test_spot_execution_busts.py deleted file mode 100644 index b29acb12..00000000 --- a/tests/test_spot/test_spot_execution_busts.py +++ /dev/null @@ -1,771 +0,0 @@ -""" -Spot Execution Busts Tests - -Tests for the spot execution bust (failed spot fill) feature: -- REST API: GET /v2/wallet/{address}/spotExecutionBusts -- REST API: GET /v2/market/{symbol}/spotExecutionBusts -- WebSocket: /v2/wallet/{address}/spotExecutionBusts channel -- WebSocket: /v2/market/{symbol}/spotExecutionBusts channel - -These tests verify: -1. REST endpoints return valid responses with correct structure -2. WebSocket bust subscriptions are established successfully -3. Data consistency between wallet and market bust endpoints -4. Bust model fields are correctly typed and populated (when busts exist) - -Note: Trade busts are exceptional events (on-chain settlement reverts). -These tests verify the API/SDK plumbing works correctly. In normal conditions, -bust lists will be empty. When busts do exist, we validate their structure. -""" - -import asyncio -import logging -import time - -import pytest - -from sdk.open_api.exceptions import ApiException -from sdk.open_api.models.spot_execution_bust import SpotExecutionBust -from sdk.open_api.models.spot_execution_bust_list import SpotExecutionBustList -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") - - -# ============================================================================= -# Helper: validate bust structure -# ============================================================================= - - -def validate_bust_fields(bust: SpotExecutionBust, expected_symbol: str | None = None) -> None: - """Validate that a SpotExecutionBust has all required fields with correct types.""" - assert bust.symbol is not None, "Bust symbol should not be None" - assert isinstance(bust.symbol, str), f"Bust symbol should be str, got {type(bust.symbol)}" - - if expected_symbol is not None: - assert bust.symbol == expected_symbol, f"Expected symbol {expected_symbol}, got {bust.symbol}" - - assert bust.account_id is not None, "Bust account_id should not be None" - assert isinstance(bust.account_id, int), f"Bust account_id should be int, got {type(bust.account_id)}" - - assert bust.exchange_id is not None, "Bust exchange_id should not be None" - assert isinstance(bust.exchange_id, int), f"Bust exchange_id should be int, got {type(bust.exchange_id)}" - - assert bust.maker_account_id is not None, "Bust maker_account_id should not be None" - assert isinstance( - bust.maker_account_id, int - ), f"Bust maker_account_id should be int, got {type(bust.maker_account_id)}" - - assert bust.order_id is not None, "Bust order_id should not be None" - assert isinstance(bust.order_id, str), f"Bust order_id should be str, got {type(bust.order_id)}" - - assert bust.maker_order_id is not None, "Bust maker_order_id should not be None" - assert isinstance(bust.maker_order_id, str), f"Bust maker_order_id should be str, got {type(bust.maker_order_id)}" - - assert bust.qty is not None, "Bust qty should not be None" - assert isinstance(bust.qty, str), f"Bust qty should be str, got {type(bust.qty)}" - assert float(bust.qty) > 0, f"Bust qty should be positive, got {bust.qty}" - - assert bust.side is not None, "Bust side should not be None" - side_val = bust.side.value if hasattr(bust.side, "value") else bust.side - assert side_val in ("B", "A"), f"Bust side should be 'B' or 'A', got {side_val}" - - assert bust.price is not None, "Bust price should not be None" - assert isinstance(bust.price, str), f"Bust price should be str, got {type(bust.price)}" - - assert bust.reason is not None, "Bust reason should not be None" - assert isinstance(bust.reason, str), f"Bust reason should be str, got {type(bust.reason)}" - - assert bust.timestamp is not None, "Bust timestamp should not be None" - assert isinstance(bust.timestamp, int), f"Bust timestamp should be int, got {type(bust.timestamp)}" - assert bust.timestamp > 0, f"Bust timestamp should be positive, got {bust.timestamp}" - - -# ============================================================================= -# REST API TESTS - Wallet Spot Execution Busts -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.rest_api -@pytest.mark.asyncio -async def test_rest_get_wallet_spot_execution_busts_structure( - spot_config: SpotTestConfig, spot_tester: ReyaTester -): # pylint: disable=unused-argument - """ - Test wallet spot execution busts REST endpoint returns correct structure. - - Verifies: - - Endpoint returns a valid SpotExecutionBustList response - - Response has 'data' and 'meta' attributes - - If busts exist, each bust has correct field types - """ - logger.info("=" * 80) - logger.info("WALLET SPOT EXECUTION BUSTS STRUCTURE TEST") - logger.info("=" * 80) - - wallet_address = spot_tester.owner_wallet_address - assert wallet_address is not None, "Wallet address required" - - bust_list: SpotExecutionBustList = await spot_tester.client.wallet.get_wallet_spot_execution_busts( - address=wallet_address - ) - - # Verify response structure - assert bust_list is not None, "Response should not be None" - assert isinstance(bust_list, SpotExecutionBustList), f"Expected SpotExecutionBustList, got {type(bust_list)}" - assert hasattr(bust_list, "data"), "Response should have 'data' attribute" - assert hasattr(bust_list, "meta"), "Response should have 'meta' attribute" - assert isinstance(bust_list.data, list), "data should be a list" - - logger.info(f"Wallet spot execution busts returned: {len(bust_list.data)} bust(s)") - - # Validate bust fields if any exist - for bust in bust_list.data[:5]: - assert isinstance(bust, SpotExecutionBust), f"Expected SpotExecutionBust, got {type(bust)}" - validate_bust_fields(bust) - logger.info( - f" - {bust.symbol}: qty={bust.qty}, price={bust.price}, " - f"order_id={bust.order_id}, reason={bust.reason[:20]}..." - ) - - logger.info("✅ WALLET SPOT EXECUTION BUSTS STRUCTURE TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.rest_api -@pytest.mark.asyncio -async def test_rest_get_wallet_spot_execution_busts_via_client_wrapper( - spot_config: SpotTestConfig, spot_tester: ReyaTester -): # pylint: disable=unused-argument - """ - Test the convenience wrapper client.get_spot_execution_busts(). - - Verifies the high-level client method works correctly and returns - the same structure as the direct wallet API call. - """ - logger.info("=" * 80) - logger.info("WALLET SPOT EXECUTION BUSTS (CLIENT WRAPPER) TEST") - logger.info("=" * 80) - - # Use the high-level convenience method - bust_list: SpotExecutionBustList = await spot_tester.client.get_spot_execution_busts() - - assert bust_list is not None, "Response should not be None" - assert isinstance(bust_list, SpotExecutionBustList), f"Expected SpotExecutionBustList, got {type(bust_list)}" - assert isinstance(bust_list.data, list), "data should be a list" - - logger.info(f"Client wrapper returned: {len(bust_list.data)} bust(s)") - - # Validate bust fields if any exist - for bust in bust_list.data[:5]: - validate_bust_fields(bust) - - logger.info("✅ WALLET SPOT EXECUTION BUSTS (CLIENT WRAPPER) TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.rest_api -@pytest.mark.asyncio -async def test_rest_get_wallet_spot_execution_busts_via_data_ops( - spot_config: SpotTestConfig, spot_tester: ReyaTester -): # pylint: disable=unused-argument - """ - Test the DataOperations.spot_execution_busts() helper. - - Verifies the test helper returns a clean list of SpotExecutionBust objects. - """ - logger.info("=" * 80) - logger.info("WALLET SPOT EXECUTION BUSTS (DATA OPS) TEST") - logger.info("=" * 80) - - busts = await spot_tester.data.spot_execution_busts() - - assert isinstance(busts, list), f"Expected list, got {type(busts)}" - logger.info(f"DataOperations returned: {len(busts)} bust(s)") - - for bust in busts[:5]: - validate_bust_fields(bust) - - logger.info("✅ WALLET SPOT EXECUTION BUSTS (DATA OPS) TEST COMPLETED") - - -# ============================================================================= -# REST API TESTS - Market Spot Execution Busts -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.rest_api -@pytest.mark.asyncio -async def test_rest_get_market_spot_execution_busts_structure(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test market spot execution busts REST endpoint returns correct structure. - - Verifies: - - Endpoint returns a valid SpotExecutionBustList response - - Response has 'data' and 'meta' attributes - - If busts exist, all belong to the queried market symbol - """ - logger.info("=" * 80) - logger.info(f"MARKET SPOT EXECUTION BUSTS STRUCTURE TEST: {spot_config.symbol}") - logger.info("=" * 80) - - bust_list: SpotExecutionBustList = await spot_tester.client.markets.get_market_spot_execution_busts( - symbol=spot_config.symbol - ) - - # Verify response structure - assert bust_list is not None, "Response should not be None" - assert isinstance(bust_list, SpotExecutionBustList), f"Expected SpotExecutionBustList, got {type(bust_list)}" - assert hasattr(bust_list, "data"), "Response should have 'data' attribute" - assert hasattr(bust_list, "meta"), "Response should have 'meta' attribute" - assert isinstance(bust_list.data, list), "data should be a list" - - logger.info(f"Market spot execution busts for {spot_config.symbol}: {len(bust_list.data)} bust(s)") - - # Validate bust fields and symbol consistency - for bust in bust_list.data[:5]: - assert isinstance(bust, SpotExecutionBust), f"Expected SpotExecutionBust, got {type(bust)}" - validate_bust_fields(bust, expected_symbol=spot_config.symbol) - logger.info( - f" - qty={bust.qty}, price={bust.price}, " f"order_id={bust.order_id}, reason={bust.reason[:20]}..." - ) - - logger.info("✅ MARKET SPOT EXECUTION BUSTS STRUCTURE TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.rest_api -@pytest.mark.asyncio -async def test_rest_get_market_spot_execution_busts_via_data_ops(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test the DataOperations.market_spot_execution_busts() helper. - - Verifies the test helper returns a clean list of SpotExecutionBust objects - for a specific market. - """ - logger.info("=" * 80) - logger.info(f"MARKET SPOT EXECUTION BUSTS (DATA OPS) TEST: {spot_config.symbol}") - logger.info("=" * 80) - - busts = await spot_tester.data.market_spot_execution_busts(spot_config.symbol) - - assert isinstance(busts, list), f"Expected list, got {type(busts)}" - logger.info(f"DataOperations returned: {len(busts)} bust(s) for {spot_config.symbol}") - - for bust in busts[:5]: - validate_bust_fields(bust, expected_symbol=spot_config.symbol) - - logger.info("✅ MARKET SPOT EXECUTION BUSTS (DATA OPS) TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.rest_api -@pytest.mark.asyncio -async def test_rest_wallet_and_market_busts_consistency(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test that wallet and market bust endpoints return consistent data. - - If busts exist for the configured symbol, they should appear in both - the wallet-level and market-level endpoints. - """ - logger.info("=" * 80) - logger.info(f"WALLET/MARKET BUST CONSISTENCY TEST: {spot_config.symbol}") - logger.info("=" * 80) - - # Fetch from both endpoints - wallet_busts = await spot_tester.data.spot_execution_busts() - market_busts = await spot_tester.data.market_spot_execution_busts(spot_config.symbol) - - logger.info(f"Wallet busts: {len(wallet_busts)}, Market busts ({spot_config.symbol}): {len(market_busts)}") - - # Filter wallet busts to only this market's symbol - wallet_busts_for_symbol = [b for b in wallet_busts if b.symbol == spot_config.symbol] - logger.info(f"Wallet busts for {spot_config.symbol}: {len(wallet_busts_for_symbol)}") - - # Market busts for the symbol should be a subset of or equal to wallet busts for that symbol - # (wallet sees busts where it's either taker or maker; market sees all busts for the symbol) - # Just verify both are valid lists with consistent structure - for bust in wallet_busts_for_symbol[:5]: - validate_bust_fields(bust, expected_symbol=spot_config.symbol) - - for bust in market_busts[:5]: - validate_bust_fields(bust, expected_symbol=spot_config.symbol) - - logger.info("✅ WALLET/MARKET BUST CONSISTENCY TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.rest_api -@pytest.mark.asyncio -async def test_rest_get_market_spot_execution_busts_pagination(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test market spot execution busts REST endpoint supports time-based pagination. - - Verifies start_time and end_time parameters work correctly. - """ - logger.info("=" * 80) - logger.info(f"MARKET SPOT EXECUTION BUSTS PAGINATION TEST: {spot_config.symbol}") - logger.info("=" * 80) - - # Fetch all busts first - all_busts: SpotExecutionBustList = await spot_tester.client.markets.get_market_spot_execution_busts( - symbol=spot_config.symbol - ) - - logger.info(f"Total busts: {len(all_busts.data)}") - - # Test with end_time filter (current time) - should return same or fewer results - current_time_ms = int(time.time() * 1000) - filtered_busts: SpotExecutionBustList = await spot_tester.client.markets.get_market_spot_execution_busts( - symbol=spot_config.symbol, end_time=current_time_ms - ) - - assert filtered_busts is not None, "Filtered response should not be None" - assert isinstance(filtered_busts.data, list), "Filtered data should be a list" - logger.info(f"Filtered busts (end_time=now): {len(filtered_busts.data)}") - - # Test with start_time in the future - should return empty - future_time_ms = int((time.time() + 86400) * 1000) # 24 hours from now - future_busts: SpotExecutionBustList = await spot_tester.client.markets.get_market_spot_execution_busts( - symbol=spot_config.symbol, start_time=future_time_ms - ) - - assert future_busts is not None, "Future response should not be None" - assert len(future_busts.data) == 0, f"Expected 0 busts for future start_time, got {len(future_busts.data)}" - logger.info("✅ No busts returned for future start_time (correct)") - - # If busts exist, test time-based filtering - if len(all_busts.data) >= 2: - second_bust = all_busts.data[1] - end_ts = second_bust.timestamp - - time_filtered: SpotExecutionBustList = await spot_tester.client.markets.get_market_spot_execution_busts( - symbol=spot_config.symbol, end_time=end_ts - ) - assert len(time_filtered.data) >= 1, "Should have at least one bust before end_time" - logger.info(f"Time-filtered busts (end_time={end_ts}): {len(time_filtered.data)}") - - logger.info("✅ MARKET SPOT EXECUTION BUSTS PAGINATION TEST COMPLETED") - - -# ============================================================================= -# WEBSOCKET TESTS - Bust Subscriptions -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.websocket -@pytest.mark.asyncio -async def test_ws_wallet_spot_execution_busts_subscription( - spot_config: SpotTestConfig, spot_tester: ReyaTester -): # pylint: disable=unused-argument - """ - Test WebSocket subscription to wallet spot execution busts channel. - - Verifies: - - Subscription to /v2/wallet/{address}/spotExecutionBusts succeeds - - The bust EventStore is initialized and accessible - - No crash occurs when subscribing to the bust channel - """ - logger.info("=" * 80) - logger.info("WS WALLET SPOT EXECUTION BUSTS SUBSCRIPTION TEST") - logger.info("=" * 80) - - # The wallet bust subscription is automatically established in on_open - # Just verify the EventStore exists and is accessible - assert spot_tester.ws.spot_execution_busts is not None, "Bust EventStore should exist" - - # Verify initial state - bust_count = len(spot_tester.ws.spot_execution_busts) - logger.info(f"Current wallet bust count: {bust_count}") - - # Verify clear works - spot_tester.ws.clear_spot_execution_busts() - assert len(spot_tester.ws.spot_execution_busts) == 0, "Bust store should be empty after clear" - - logger.info("✅ WS WALLET SPOT EXECUTION BUSTS SUBSCRIPTION TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.websocket -@pytest.mark.asyncio -async def test_ws_market_spot_execution_busts_subscription(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test WebSocket subscription to market spot execution busts channel. - - Verifies: - - Explicit subscription to /v2/market/{symbol}/spotExecutionBusts succeeds - - The market bust EventStore is initialized correctly - """ - logger.info("=" * 80) - logger.info(f"WS MARKET SPOT EXECUTION BUSTS SUBSCRIPTION TEST: {spot_config.symbol}") - logger.info("=" * 80) - - # Subscribe to market-level bust channel - spot_tester.ws.subscribe_to_market_spot_execution_busts(spot_config.symbol) - - # Allow time for subscription confirmation - await asyncio.sleep(1.0) - - # Verify market bust store exists (may or may not have data) - logger.info(f"Market bust stores: {list(spot_tester.ws.market_spot_execution_busts.keys())}") - - # Verify clear works - spot_tester.ws.clear_market_spot_execution_busts(spot_config.symbol) - if spot_config.symbol in spot_tester.ws.market_spot_execution_busts: - assert len(spot_tester.ws.market_spot_execution_busts[spot_config.symbol]) == 0 - - logger.info("✅ WS MARKET SPOT EXECUTION BUSTS SUBSCRIPTION TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.websocket -@pytest.mark.asyncio -async def test_ws_bust_store_operations( - spot_config: SpotTestConfig, spot_tester: ReyaTester -): # pylint: disable=unused-argument - """ - Test WebSocket bust EventStore operations. - - Verifies: - - clear() works for both wallet and market bust stores - - clear_all via ws.clear() includes bust stores - - Bust stores are properly isolated from execution stores - """ - logger.info("=" * 80) - logger.info("WS BUST STORE OPERATIONS TEST") - logger.info("=" * 80) - - # Record initial counts - initial_spot_exec_count = len(spot_tester.ws.spot_executions) - initial_bust_count = len(spot_tester.ws.spot_execution_busts) - logger.info(f"Initial spot executions: {initial_spot_exec_count}, busts: {initial_bust_count}") - - # Clear only busts - should not affect spot executions - spot_tester.ws.clear_spot_execution_busts() - assert len(spot_tester.ws.spot_execution_busts) == 0, "Bust store should be empty" - assert ( - len(spot_tester.ws.spot_executions) == initial_spot_exec_count - ), "Spot executions should be unchanged after clearing busts" - - # Clear all - should clear both - spot_tester.ws.clear() - assert len(spot_tester.ws.spot_executions) == 0, "Spot executions should be empty after clear_all" - assert len(spot_tester.ws.spot_execution_busts) == 0, "Busts should be empty after clear_all" - assert len(spot_tester.ws.market_spot_execution_busts) == 0, "Market busts should be empty after clear_all" - - logger.info("✅ WS BUST STORE OPERATIONS TEST COMPLETED") - - -# ============================================================================= -# END-TO-END TESTS - Trade + Bust Verification -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.maker_taker -@pytest.mark.asyncio -async def test_successful_trade_produces_no_busts( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Test that a successful spot trade does NOT produce bust events. - - This is a critical negative test: after a normal successful trade, - there should be no new bust events for either maker or taker. - - Flow: - 1. Record bust counts before trade - 2. Execute a successful maker-taker trade - 3. Verify no new busts appeared for either party - """ - logger.info("=" * 80) - logger.info(f"SUCCESSFUL TRADE NO BUSTS 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) - - # Clear bust tracking - maker_tester.ws.clear_spot_execution_busts() - taker_tester.ws.clear_spot_execution_busts() - - # Record pre-trade bust counts via REST - maker_busts_before = await maker_tester.data.spot_execution_busts() - taker_busts_before = await taker_tester.data.spot_execution_busts() - maker_bust_count_before = len(maker_busts_before) - taker_bust_count_before = len(taker_busts_before) - - logger.info(f"Pre-trade busts: maker={maker_bust_count_before}, taker={taker_bust_count_before}") - - # Execute a normal trade via maker-taker self-matching - maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() - maker_order_id = await maker_tester.orders.create_limit(maker_params) - await maker_tester.wait.for_order_creation(maker_order_id) - - taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).ioc().build() - await taker_tester.orders.create_limit(taker_params) - await asyncio.sleep(1.0) - - logger.info("✅ Trade executed successfully") - - # Verify no new busts via WebSocket - ws_maker_busts = len(maker_tester.ws.spot_execution_busts) - ws_taker_busts = len(taker_tester.ws.spot_execution_busts) - logger.info(f"WS bust events after trade: maker={ws_maker_busts}, taker={ws_taker_busts}") - - assert ws_maker_busts == 0, f"Maker should have 0 new WS bust events, got {ws_maker_busts}" - assert ws_taker_busts == 0, f"Taker should have 0 new WS bust events, got {ws_taker_busts}" - - # Verify no new busts via REST - maker_busts_after = await maker_tester.data.spot_execution_busts() - taker_busts_after = await taker_tester.data.spot_execution_busts() - - assert ( - len(maker_busts_after) == maker_bust_count_before - ), f"Maker REST bust count should not change: before={maker_bust_count_before}, after={len(maker_busts_after)}" - assert ( - len(taker_busts_after) == taker_bust_count_before - ), f"Taker REST bust count should not change: before={taker_bust_count_before}, after={len(taker_busts_after)}" - - logger.info("✅ No busts produced - successful trade verified clean") - - # Cleanup - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - logger.info("✅ SUCCESSFUL TRADE NO BUSTS TEST COMPLETED") - - -@pytest.mark.spot -@pytest.mark.rest_api -@pytest.mark.asyncio -async def test_bust_data_is_historical_consistent(spot_config: SpotTestConfig, spot_tester: ReyaTester): - """ - Test that repeated queries to bust endpoints return consistent results. - - Verifies idempotency: calling the same endpoint twice should return - the same data (no phantom busts, no data loss between calls). - """ - logger.info("=" * 80) - logger.info("BUST DATA HISTORICAL CONSISTENCY TEST") - logger.info("=" * 80) - - # Query wallet busts twice - busts_first = await spot_tester.data.spot_execution_busts() - busts_second = await spot_tester.data.spot_execution_busts() - - assert len(busts_first) == len( - busts_second - ), f"Bust count should be consistent: first={len(busts_first)}, second={len(busts_second)}" - - # If busts exist, verify order_ids match - if busts_first: - first_ids = {b.order_id for b in busts_first} - second_ids = {b.order_id for b in busts_second} - assert first_ids == second_ids, "Bust order_ids should be identical between queries" - - # Query market busts twice - market_busts_first = await spot_tester.data.market_spot_execution_busts(spot_config.symbol) - market_busts_second = await spot_tester.data.market_spot_execution_busts(spot_config.symbol) - - assert len(market_busts_first) == len( - market_busts_second - ), f"Market bust count should be consistent: first={len(market_busts_first)}, second={len(market_busts_second)}" - - logger.info( - f"Consistency verified: wallet={len(busts_first)} busts, " - f"market({spot_config.symbol})={len(market_busts_first)} busts" - ) - - logger.info("✅ BUST DATA HISTORICAL CONSISTENCY TEST COMPLETED") - - -# ============================================================================= -# DELIBERATE BUST TEST - Trigger a bust via insufficient balance -# ============================================================================= - - -@pytest.mark.spot -@pytest.mark.maker_taker -@pytest.mark.bust -@pytest.mark.asyncio -async def test_deliberate_bust_via_insufficient_balance( - spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester -): - """ - Test that deliberately triggers a spot execution bust. - - Strategy: - - Maker posts a GTC sell at oracle×1.01 (above best bid, won't match external - liquidity) for a quantity just exceeding their base asset balance. - GTCs skip balance checks, so the order is accepted. - - Taker posts an IOC buy at the same price to cross the GTC. - The IOC balance check passes because taker has enough rUSD. - - Off-chain matching succeeds, but on-chain settlement reverts because the - maker cannot deliver the full quantity of base asset. - - The API may raise an ApiException on the IOC (expected — the executor reports - the on-chain revert). The bust event is still emitted via WS. - - The bust reason string may vary (insufficient balance, price deviation, etc.) - depending on which on-chain check fails first. We assert a bust exists without - hardcoding a specific reason. - """ - logger.info("=" * 80) - logger.info(f"DELIBERATE BUST TEST: {spot_config.symbol}") - logger.info("=" * 80) - - # Step 1: Clean state - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - # Record bust counts before - maker_busts_before = await maker_tester.data.spot_execution_busts() - taker_busts_before = await taker_tester.data.spot_execution_busts() - market_busts_before = await maker_tester.data.market_spot_execution_busts(spot_config.symbol) - - maker_bust_count_before = len(maker_busts_before) - taker_bust_count_before = len(taker_busts_before) - market_bust_count_before = len(market_busts_before) - - logger.info( - f"Bust counts before: maker={maker_bust_count_before}, " - f"taker={taker_bust_count_before}, market={market_bust_count_before}" - ) - - # Clear WS bust tracking - maker_tester.ws.clear_spot_execution_busts() - taker_tester.ws.clear_spot_execution_busts() - - # Step 2: Refresh order book — skip if external liquidity present - await spot_config.refresh_order_book(maker_tester.data) - if spot_config.has_any_external_liquidity: - pytest.skip( - "Skipping deliberate bust test: external liquidity present on the book. " - "Run with an empty book (no external MM) to avoid unintended matches." - ) - - # Step 3: Get maker's actual base asset balance and taker's rUSD - base_asset = spot_config.base_asset # e.g., "ETH" (API returns unwrapped names) - maker_balance = await maker_tester.data.balance(base_asset) - assert maker_balance is not None, f"Maker should have {base_asset} balance" - maker_base_balance = float(maker_balance.real_balance) - logger.info(f"Maker {base_asset} balance: {maker_base_balance}") - - taker_rusd = await taker_tester.data.balance("RUSD") - assert taker_rusd is not None, "Taker should have RUSD balance" - taker_rusd_balance = float(taker_rusd.real_balance) - logger.info(f"Taker RUSD balance: {taker_rusd_balance}") - - # Step 4: Calculate bust parameters - # Price near oracle — within circuit breaker (±5%) so on-chain price check passes. - # With no external liquidity, only our two accounts can match. - bust_price = str(spot_config.sell_price(1.01)) # oracle × 1.01 - - # bust_qty must exceed maker's balance so settlement fails, - # but taker must afford bust_qty * bust_price in rUSD - min_qty_val = float(spot_config.min_qty) - bust_price_f = float(bust_price) - taker_max_qty = taker_rusd_balance / bust_price_f # max qty taker can afford - bust_qty_f = maker_base_balance + min_qty_val * 10 # just above maker's balance - - assert bust_qty_f < taker_max_qty, ( - f"Taker cannot afford bust_qty ({bust_qty_f:.6f}) × price ({bust_price}): " - f"needs ${bust_qty_f * bust_price_f:.2f}, has ${taker_rusd_balance:.2f}. " - f"Fund taker with more RUSD." - ) - - bust_qty = str(round(bust_qty_f, 6)) - taker_rusd_needed = float(bust_qty) * bust_price_f - logger.info(f"Bust params: qty={bust_qty}, price={bust_price}, " f"taker rUSD needed={taker_rusd_needed:.2f}") - - # Record balances before to verify no change after bust - maker_balances_before = await maker_tester.data.balances() - taker_balances_before = await taker_tester.data.balances() - - # Step 5: Maker posts GTC sell (no balance check — exceeds actual balance) - logger.info(f"Placing maker GTC sell: qty={bust_qty} at price={bust_price}") - maker_order_params = OrderBuilder.from_config(spot_config).sell().qty(bust_qty).price(bust_price).gtc().build() - maker_order_id = await maker_tester.orders.create_limit(maker_order_params) - logger.info(f"Maker GTC sell placed: order_id={maker_order_id}") - - # Wait for GTC to appear on book - await maker_tester.wait.for_order_creation(maker_order_id) - logger.info("Maker GTC confirmed on book") - - # Step 6: Taker posts IOC buy to cross the GTC - # The API may raise ApiException when on-chain settlement reverts — this is expected - logger.info(f"Placing taker IOC buy: qty={bust_qty} at price={bust_price}") - taker_order_params = OrderBuilder.from_config(spot_config).buy().qty(bust_qty).price(bust_price).ioc().build() - taker_order_id = None - try: - taker_order_id = await taker_tester.orders.create_limit(taker_order_params) - logger.info(f"Taker IOC buy placed: order_id={taker_order_id}") - except ApiException as e: - logger.info(f"Taker IOC raised ApiException (expected for bust): {e.body}") - - # Step 7: Wait for bust event via WS (use maker_order_id since bust references it) - logger.info("Waiting for bust event...") - bust = await maker_tester.wait.for_spot_execution_bust(order_id=maker_order_id, timeout=20) - logger.info( - f"Bust received: order_id={bust.order_id}, maker_order_id={bust.maker_order_id}, " - f"qty={bust.qty}, price={bust.price}, reason={bust.reason}" - ) - - # Step 8: Validate bust fields - validate_bust_fields(bust, expected_symbol=spot_config.symbol) - assert bust.reason, "Bust reason should be non-empty" - logger.info(f"Bust reason: {bust.reason}") - - # Step 9: Verify bust counts increased via REST - maker_busts_after = await maker_tester.data.spot_execution_busts() - taker_busts_after = await taker_tester.data.spot_execution_busts() - market_busts_after = await maker_tester.data.market_spot_execution_busts(spot_config.symbol) - - assert ( - len(maker_busts_after) > maker_bust_count_before - ), f"Maker bust count should increase: before={maker_bust_count_before}, after={len(maker_busts_after)}" - assert ( - len(taker_busts_after) > taker_bust_count_before - ), f"Taker bust count should increase: before={taker_bust_count_before}, after={len(taker_busts_after)}" - assert ( - len(market_busts_after) > market_bust_count_before - ), f"Market bust count should increase: before={market_bust_count_before}, after={len(market_busts_after)}" - - logger.info( - f"Bust counts after: maker={len(maker_busts_after)}, " - f"taker={len(taker_busts_after)}, market={len(market_busts_after)}" - ) - - # Step 10: Verify balances unchanged (settlement reverted — no assets moved) - maker_balances_after = await maker_tester.data.balances() - taker_balances_after = await taker_tester.data.balances() - - for asset in [base_asset, "RUSD"]: - before = maker_balances_before.get(asset) - after = maker_balances_after.get(asset) - if before and after: - assert before.real_balance == after.real_balance, ( - f"Maker {asset} balance should be unchanged after bust: " - f"before={before.real_balance}, after={after.real_balance}" - ) - - before = taker_balances_before.get(asset) - after = taker_balances_after.get(asset) - if before and after: - assert before.real_balance == after.real_balance, ( - f"Taker {asset} balance should be unchanged after bust: " - f"before={before.real_balance}, after={after.real_balance}" - ) - - logger.info("✅ Balances unchanged — settlement correctly reverted") - - # Cleanup: cancel any lingering maker GTC - await maker_tester.orders.close_all(fail_if_none=False) - await taker_tester.orders.close_all(fail_if_none=False) - - logger.info("✅ DELIBERATE BUST TEST COMPLETED") diff --git a/tests/test_spot/test_state_resilience.py b/tests/test_spot/test_state_resilience.py index 6c1c9e5c..892af504 100644 --- a/tests/test_spot/test_state_resilience.py +++ b/tests/test_spot/test_state_resilience.py @@ -61,6 +61,7 @@ async def test_spot_order_survives_ws_reconnect(spot_config: SpotTestConfig, spo logger.info(f"Placing GTC buy at ${order_price:.2f}...") order_id = await spot_tester.orders.create_limit(order_params) + assert order_id is not None, "create_limit must return an order_id for this test" await spot_tester.wait.for_order_creation(order_id) logger.info(f"✅ Order created: {order_id}") @@ -263,6 +264,7 @@ async def test_spot_ws_rest_consistency_after_activity( # WS should show CANCELLED for remaining orders for order_id in remaining_order_ids: + assert order_id is not None, "remaining order_id should be set" ws_order = maker_tester.ws.order_changes.get(order_id) assert ws_order is not None, f"Order {order_id} should be in WS" ws_status = ws_order.status.value if hasattr(ws_order.status, "value") else ws_order.status diff --git a/tests/ws_exec/mvp.py b/tests/ws_exec/test_ws_exec.py similarity index 64% rename from tests/ws_exec/mvp.py rename to tests/ws_exec/test_ws_exec.py index f7b3667f..58d2228a 100644 --- a/tests/ws_exec/mvp.py +++ b/tests/ws_exec/test_ws_exec.py @@ -1,29 +1,29 @@ -"""ws-exec end-to-end test harness. +"""ws-exec end-to-end tests (live, devnet-gated). Exercises every supported operation and variant of the ws-exec WebSocket -order-entry service, plus the highest-signal error modes. Designed to run -against a live testnet (Cronos) deployment. +order-entry service, plus the highest-signal error modes, against a live +deployment. These are *integration* tests: the whole module is skipped +unless `REYA_WS_EXEC_URL` (+ SPOT_*_1 / PERP_*_1 credentials) is set, so a +normal `pytest` run on a machine without ws-exec access collects-and-skips +cleanly. Point `REYA_WS_EXEC_URL` at the target relayer to run them, e.g. +`REYA_WS_EXEC_URL=wss://ws-exec-devnet.reya-cronos.network`. Happy paths (11) — all driven via :class:`ReyaWsExecClient`: 0. Application ping/pong probe 1. Spot createOrder (LIMIT GTC, far-out price - rests) 2. Spot cancelOrder by orderId - 3. Spot createOrder + cancelOrder by clientOrderId (alternative cancel path) + 3. Spot createOrder + cancelOrder by clientOrderId 4. Spot cancelAll, symbol-scoped (opens N orders, mass-cancels them) 5. Spot cancelAll, account-wide (no symbol scope) 6. Perp createOrder (LIMIT GTC conditional, rests) + cancel 7. Perp createOrder (TP) + cancel 8. Perp createOrder (SL) + cancel - 9. Perp createOrder (LIMIT IOC, opens a small long via the pool) - 10. Perp createOrder (LIMIT IOC, reduceOnly=true, closes the long from 9) + 9/10. Perp IOC open long + reduce-only close (paired in one test so the + position is always unwound even if an assertion fails). -Flows 9 and 10 are paired with a ``try/finally`` that always attempts the -reduce-only close if 9 succeeded, even if a later flow raises -- a leaked -position is the most expensive failure mode in this script. - -Error paths (6) -- driven on a separate raw WebSocket so the test harness -can send intentionally-malformed payloads without interfering with the -high-level client's in-flight dispatch map: +Error paths (6) -- driven on a separate raw WebSocket so the harness can send +intentionally-malformed payloads without racing the high-level client's +in-flight dispatch map: E1. DUPLICATE_REQUEST_ID (framing) -- same `id` in flight twice E2. MALFORMED_JSON (framing) -- non-JSON frame E3. UNKNOWN_TYPE (framing) -- `type: "foobar"` @@ -31,26 +31,27 @@ E5. ORDER_DEADLINE_PASSED_ERROR (per-op) -- past expiresAfter E6. UNAUTHORIZED_SIGNATURE_ERROR (per-op) -- signer/signerWallet mismatch -Run with: - poetry shell - python -m tests.ws_exec.mvp +E5/E6 build their payloads via the unified `build_create_limit_order_payload` +(then tweak the deadline / signerWallet) — the legacy `sign_raw_order` / +`encode_inputs_limit_order` signing surface was removed in the 2.3.x unified +migration. -Requires .env populated with SPOT_*_1 + PERP_*_1 credentials and CHAIN_ID, and -the configured chain's ws-exec relayer EOA funded with native gas (perp IOC -flows settle on-chain via OrdersGateway::execute). +Requires the configured chain's ws-exec relayer EOA funded with native gas +(perp IOC flows settle on-chain via OrdersGateway::execute). """ from __future__ import annotations -import asyncio import json import os import ssl -import sys import time import uuid +from dataclasses import dataclass from decimal import Decimal +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 @@ -59,7 +60,29 @@ from sdk.reya_rest_api import ReyaTradingClient 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, WsExecOperationError, WsExecProtocolError +from sdk.reya_ws_exec import ReyaWsExecClient + +load_dotenv() + +# Live ws-exec integration tests are gated on a relayer URL + signing creds. +# Without them the whole module collects-and-skips so CI stays green on +# machines that can't reach a ws-exec deployment. +_REQUIRED_ENV = ( + "REYA_WS_EXEC_URL", + "PERP_PRIVATE_KEY_1", + "PERP_ACCOUNT_ID_1", + "SPOT_PRIVATE_KEY_1", + "SPOT_ACCOUNT_ID_1", +) +_MISSING_ENV = [_k for _k in _REQUIRED_ENV if not os.environ.get(_k)] + +pytestmark = pytest.mark.skipif( + bool(_MISSING_ENV), + reason=( + "ws-exec live tests need " + ", ".join(_REQUIRED_ENV) + " in the environment " + "(set REYA_WS_EXEC_URL + SPOT_*_1/PERP_*_1 to run); missing: " + ", ".join(_MISSING_ENV) + ), +) # ---- Test parameters -------------------------------------------------------- @@ -209,12 +232,14 @@ async def flow_perp_create_trigger_and_cancel( client: ReyaWsExecClient, trigger_type: OrderType, trigger_px: str, + qty: Decimal, label: str, ) -> None: resp = await client.create_trigger_order( TriggerOrderParameters( symbol=PERP_SYMBOL, is_buy=False, + qty=str(qty), trigger_px=trigger_px, trigger_type=trigger_type, ) @@ -277,7 +302,12 @@ def _raw_connect(url: str) -> WebSocket: def _raw_send_envelope(ws: WebSocket, msg_type: str, env_id: str, payload: dict) -> None: - ws.send(json.dumps({"type": msg_type, "id": env_id, "payload": payload})) + # 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( @@ -453,43 +483,23 @@ async def flow_err_invalid_nonce( def flow_err_order_deadline_passed(ws_url: str, rest_client: ReyaTradingClient, qty: Decimal) -> None: - """Submit a createOrder with `expiresAfter` in the past. The validator chain - catches it as ORDER_DEADLINE_PASSED_ERROR or INPUT_VALIDATION_ERROR.""" - # Build a normal payload, then deliberately rewind the deadline below now - # and re-sign with the same arguments. We bypass the high-level client's - # spot GTC validation by sticking to the raw signing pipeline. - signer = rest_client.signature_generator - config = rest_client.config - assert config.account_id is not None, "rest_client.config.account_id must be populated for E5" - account_id = config.account_id - market_id = rest_client.get_market_id_from_symbol(SPOT_SYMBOL) - nonce = rest_client.get_next_nonce() - deadline = int(time.time()) - 60 # 60s in the past - inputs = signer.encode_inputs_limit_order(is_buy=True, limit_px=Decimal(SPOT_LIMIT_PX), qty=qty) - signature = signer.sign_raw_order( - account_id=account_id, - market_id=market_id, - exchange_id=config.dex_id, - counterparty_account_ids=[], - order_type=6, # OrdersGatewayOrderType.LIMIT_ORDER_SPOT - inputs=inputs, - deadline=deadline, - nonce=nonce, + """Submit a createOrder with `expiresAfter`/`deadline` in the past. The + validator chain catches it as ORDER_DEADLINE_PASSED_ERROR or + INPUT_VALIDATION_ERROR. + + Built via the unified `build_create_limit_order_payload` with an explicit + past `deadline` (which the builder also mirrors into `expiresAfter`), so + the payload is correctly signed but dead-on-arrival.""" + payload, _nonce = rest_client.build_create_limit_order_payload( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=SPOT_LIMIT_PX, + qty=str(qty), + time_in_force=TimeInForce.GTC, + deadline=int(time.time()) - 60, # 60s in the past + ) ) - payload = { - "accountId": account_id, - "symbol": SPOT_SYMBOL, - "exchangeId": config.dex_id, - "isBuy": True, - "limitPx": SPOT_LIMIT_PX, - "qty": str(qty), - "orderType": "LIMIT", - "timeInForce": "GTC", - "expiresAfter": deadline, - "signature": signature, - "nonce": str(nonce), - "signerWallet": signer.signer_wallet_address, - } ws = _raw_connect(ws_url) try: @@ -513,40 +523,22 @@ def flow_err_unauthorized_signature( qty: Decimal, ) -> None: """Sign the payload with one account's key but declare the OTHER wallet - as ``signerWallet``. Server recovers the actual signer and rejects.""" - signer = rest_client.signature_generator - config = rest_client.config - assert config.account_id is not None, "rest_client.config.account_id must be populated for E6" - account_id = config.account_id - market_id = rest_client.get_market_id_from_symbol(SPOT_SYMBOL) - nonce = rest_client.get_next_nonce() - deadline = int(time.time()) + 86_400 - inputs = signer.encode_inputs_limit_order(is_buy=True, limit_px=Decimal(SPOT_LIMIT_PX), qty=qty) - signature = signer.sign_raw_order( - account_id=account_id, - market_id=market_id, - exchange_id=config.dex_id, - counterparty_account_ids=[], - order_type=6, - inputs=inputs, - deadline=deadline, - nonce=nonce, + as ``signerWallet``. Server recovers the actual signer and rejects. + + Built via the unified `build_create_limit_order_payload` (signed by + ``rest_client``), then the `signerWallet` field is overwritten with the + other account's wallet so recovery-vs-declared diverge.""" + payload, _nonce = rest_client.build_create_limit_order_payload( + LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + limit_px=SPOT_LIMIT_PX, + qty=str(qty), + time_in_force=TimeInForce.GTC, + ) ) - payload = { - "accountId": account_id, - "symbol": SPOT_SYMBOL, - "exchangeId": config.dex_id, - "isBuy": True, - "limitPx": SPOT_LIMIT_PX, - "qty": str(qty), - "orderType": "LIMIT", - "timeInForce": "GTC", - "expiresAfter": deadline, - "signature": signature, - "nonce": str(nonce), - # Declared as the OTHER wallet -> recovery vs declared mismatch - "signerWallet": other_rest_client.signature_generator.signer_wallet_address, - } + # Declared as the OTHER wallet -> recovered signer vs declared mismatch. + payload["signerWallet"] = other_rest_client.signer_wallet_address ws = _raw_connect(ws_url) try: @@ -609,59 +601,51 @@ async def _build_rest_client( return client -# ---- Main ------------------------------------------------------------------ +# ---- Pytest fixtures ------------------------------------------------------- -async def main() -> int: - load_dotenv() +@dataclass +class _WsExecHarness: + """Shared, module-scoped state for the ws-exec flows.""" - ws_url = os.environ.get("REYA_WS_EXEC_URL", DEFAULT_WS_EXEC_URL) - print(f"Connecting to {ws_url}") + ws_url: str + spot_rest: ReyaTradingClient + perp_rest: ReyaTradingClient + spot_rest_2: ReyaTradingClient | None + spot_qty: Decimal + perp_qty: Decimal + +@pytest_asyncio.fixture(loop_scope="session", scope="module") +async def harness(): + """Build REST clients, resolve market min-qtys, expose the ws-exec URL. + + Module-scoped: one set of clients shared across every ws-exec flow. The + module-level skipif guarantees the required env is present before we get + here, so a missing-creds path would be a real error, not a skip.""" + ws_url = os.environ.get("REYA_WS_EXEC_URL", DEFAULT_WS_EXEC_URL) spot_rest = await _build_rest_client(perp=False, account_number=1) perp_rest = await _build_rest_client(perp=True) - - spot_rest_2: ReyaTradingClient | None = None try: - spot_rest_2 = await _build_rest_client(perp=False, account_number=2) + spot_rest_2: ReyaTradingClient | None = await _build_rest_client(perp=False, account_number=2) except (RuntimeError, ValueError): - pass # SPOT_*_2 absent -> the E6 signer-mismatch flow is skipped below. - - # Resolve market_ids + min_qty via the SDK reference resource so we don't - # bypass our own client. (The earlier mvp.py reached around the SDK via - # urllib + falsy-fallback dict lookups.) - 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()} - if SPOT_SYMBOL not in spot_markets: - raise RuntimeError(f"{SPOT_SYMBOL} not found in /spotMarketDefinitions") - if PERP_SYMBOL not in perp_markets: - raise RuntimeError(f"{PERP_SYMBOL} not found in /marketDefinitions") - spot_market = spot_markets[SPOT_SYMBOL] - perp_market = perp_markets[PERP_SYMBOL] - spot_qty = Decimal(str(spot_market.min_order_qty)) - perp_qty = Decimal(str(perp_market.min_order_qty)) - - print(f" {SPOT_SYMBOL}: marketId={spot_market.market_id} minQty={spot_qty}") - print(f" {PERP_SYMBOL}: marketId={perp_market.market_id} minQty={perp_qty}") - print(f" Spot account #1: accountId={spot_rest.config.account_id} " f"signer={spot_rest.signer_wallet_address}") - print(f" Perp account #1: accountId={perp_rest.config.account_id} " f"signer={perp_rest.signer_wallet_address}") - if spot_rest_2 is not None: - print( - f" Spot account #2: accountId={spot_rest_2.config.account_id} " - f"signer={spot_rest_2.signer_wallet_address} (used for signer-mismatch test)" - ) + spot_rest_2 = None # SPOT_*_2 absent -> E6 signer-mismatch test skips. try: - async with await _connect_ws_exec_client(spot_rest, ws_url) as spot_ws: - await _run_spot_flows(spot_ws, spot_qty) - - async with await _connect_ws_exec_client(perp_rest, ws_url) as perp_ws: - await _run_perp_flows(perp_ws, perp_qty) - - await _run_error_flows(ws_url, spot_rest, spot_rest_2, spot_qty) - - print("\nall flows passed") - return 0 + 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()} + if SPOT_SYMBOL not in spot_markets: + raise RuntimeError(f"{SPOT_SYMBOL} not found in /spotMarketDefinitions") + if PERP_SYMBOL not in perp_markets: + raise RuntimeError(f"{PERP_SYMBOL} not found in /marketDefinitions") + yield _WsExecHarness( + ws_url=ws_url, + spot_rest=spot_rest, + perp_rest=perp_rest, + spot_rest_2=spot_rest_2, + spot_qty=Decimal(str(spot_markets[SPOT_SYMBOL].min_order_qty)), + perp_qty=Decimal(str(perp_markets[PERP_SYMBOL].min_order_qty)), + ) finally: await spot_rest.close() await perp_rest.close() @@ -669,85 +653,136 @@ async def main() -> int: await spot_rest_2.close() -async def _run_spot_flows(client: ReyaWsExecClient, qty: Decimal) -> None: - print("\n--- Flow 0: application ping/pong ---") - await flow_ping(client) +@pytest_asyncio.fixture(loop_scope="session", scope="module") +async def spot_ws(harness): # pylint: disable=redefined-outer-name + """A connected ws-exec client authenticated as the spot account.""" + async with await _connect_ws_exec_client(harness.spot_rest, harness.ws_url) as client: + yield client + + +@pytest_asyncio.fixture(loop_scope="session", scope="module") +async def perp_ws(harness): # pylint: disable=redefined-outer-name + """A connected ws-exec client authenticated as the perp account.""" + async with await _connect_ws_exec_client(harness.perp_rest, harness.ws_url) as client: + yield client + + +# ---- Happy-path tests ------------------------------------------------------ + - print("\n--- Flow 1: spot createOrder (LIMIT GTC) ---") - order_id = await flow_spot_create_order(client, qty=qty, client_order_id=_new_client_order_id()) +async def test_ping(spot_ws): # pylint: disable=redefined-outer-name + """Flow 0: application ping/pong probe.""" + await flow_ping(spot_ws) - print("\n--- Flow 2: spot cancelOrder by orderId ---") - await client.cancel_order( + +async def test_spot_create_and_cancel_by_order_id(spot_ws, harness): # pylint: disable=redefined-outer-name + """Flows 1-2: spot createOrder (GTC) then cancel by orderId.""" + order_id = await flow_spot_create_order(spot_ws, qty=harness.spot_qty, client_order_id=_new_client_order_id()) + resp = await spot_ws.cancel_order( order_id=order_id, symbol=SPOT_SYMBOL, - account_id=client.rest_client.config.account_id, + account_id=spot_ws.rest_client.config.account_id, ) - print(f" [spot] cancelOrder OK orderId={order_id}") + print(f" [spot] cancelOrder OK orderId={order_id} status={resp.status}") + + +async def test_spot_cancel_by_client_order_id(spot_ws, harness): # pylint: disable=redefined-outer-name + """Flow 3: create + cancel by clientOrderId. + + PRO-143 resolved: the ME echoes the cancelled order's resolved orderId on a + successful cancel, so the server response satisfies the (orderId-required) + CancelOrderResponse schema. Un-xfailed as a regression guard. + """ + await flow_spot_create_and_cancel_by_client_order_id(spot_ws, qty=harness.spot_qty) + + +async def test_spot_cancel_all_symbol_scoped(spot_ws, harness): # pylint: disable=redefined-outer-name + """Flow 4: open N spot orders, cancelAll scoped to the symbol.""" + await flow_spot_cancel_all(spot_ws, qty=harness.spot_qty, num_orders_to_open=3) - print("\n--- Flow 3: spot create + cancel by clientOrderId ---") - await flow_spot_create_and_cancel_by_client_order_id(client, qty=qty) - print("\n--- Flow 4: spot cancelAll (symbol-scoped) ---") - await flow_spot_cancel_all(client, qty=qty, num_orders_to_open=3) +async def test_spot_cancel_all_account_wide(spot_ws, harness): # pylint: disable=redefined-outer-name + """Flow 5: open N spot orders, cancelAll account-wide (no symbol scope).""" + await flow_spot_cancel_all_account_wide(spot_ws, qty=harness.spot_qty, num_orders_to_open=2) - print("\n--- Flow 5: spot cancelAll (account-wide) ---") - await flow_spot_cancel_all_account_wide(client, qty=qty, num_orders_to_open=2) + +# Perp order entry over ws-exec was brought up in layers and now works on devnet1 +# for LIMIT (both TIFs): +# * IOC open + reduce-only close — PerpMarketProvider bootstrap fixed (PRO-149); +# executor allowlisted on-chain (PRO-152). +# * LIMIT GTC create + cancel — cancel-by-orderId forwards the perp marketId +# (perpOB-6 "Bug 11"), deployed. +# Both run unmarked as real coverage. +# +# TP/SL triggers are a server-side facade, not a live feature yet — skip (not +# xfail) so we don't pretend to cover them. Also blocked by PRO-154 (1e18 +# expiresAfter ABI overflow on settle) and PRO-150 (TP/SL design). +_SLTP_FACADE_SKIP = pytest.mark.skip( + reason="TP/SL is a server-side facade, not a live feature yet (PRO-150 design; " + "PRO-154 1e18 expiresAfter ABI overflow blocks it) — skip until SLTP is real" +) -async def _run_perp_flows(client: ReyaWsExecClient, qty: Decimal) -> None: - print("\n--- Flow 6: perp createOrder (LIMIT GTC) + cancel ---") - await flow_perp_create_limit_gtc_and_cancel(client, qty=qty) +async def test_perp_limit_gtc_and_cancel(perp_ws, harness): # pylint: disable=redefined-outer-name + """Flow 6: perp LIMIT GTC conditional rests, then cancel.""" + await flow_perp_create_limit_gtc_and_cancel(perp_ws, qty=harness.perp_qty) - print("\n--- Flow 7: perp createOrder (TP) + cancel ---") - await flow_perp_create_trigger_and_cancel(client, OrderType.TP, PERP_TP_TRIGGER_PX, "TP") - print("\n--- Flow 8: perp createOrder (SL) + cancel ---") - await flow_perp_create_trigger_and_cancel(client, OrderType.SL, PERP_SL_TRIGGER_PX, "SL") +@_SLTP_FACADE_SKIP +async def test_perp_trigger_take_profit_and_cancel(perp_ws, harness): # pylint: disable=redefined-outer-name + """Flow 7: perp TAKE_PROFIT trigger order, then cancel.""" + await flow_perp_create_trigger_and_cancel( + perp_ws, OrderType.TAKE_PROFIT, PERP_TP_TRIGGER_PX, harness.perp_qty, "TP" + ) + - # Flows 9 and 10 are paired: 9 opens a real long, 10 is the only thing - # that closes it. The try/finally guarantees the close runs even if a - # later assertion raises, so we don't leak a position on test failure. - print("\n--- Flow 9: perp createOrder (IOC) - opens long ---") - await flow_perp_ioc_open(client, qty=qty) +@_SLTP_FACADE_SKIP +async def test_perp_trigger_stop_loss_and_cancel(perp_ws, harness): # pylint: disable=redefined-outer-name + """Flow 8: perp STOP_LOSS trigger order, then cancel.""" + await flow_perp_create_trigger_and_cancel(perp_ws, OrderType.STOP_LOSS, PERP_SL_TRIGGER_PX, harness.perp_qty, "SL") + + +async def test_perp_ioc_open_and_close(perp_ws, harness): # pylint: disable=redefined-outer-name + """Flows 9-10 (paired): perp IOC opens a min-size long, reduce-only IOC + closes it. The close always runs in ``finally`` so a failed assertion never + leaks a position.""" + await flow_perp_ioc_open(perp_ws, qty=harness.perp_qty) try: pass # placeholder for future inter-9-and-10 checks; keep paired finally: - print("\n--- Flow 10: perp createOrder (IOC, reduceOnly) - closes long ---") - try: - await flow_perp_ioc_close(client, qty=qty) - except (WsExecOperationError, WsExecProtocolError) as exc: - print(f" [cleanup] WARN: reduce-only close failed: {exc!r}") + await flow_perp_ioc_close(perp_ws, qty=harness.perp_qty) -async def _run_error_flows( - ws_url: str, - spot_rest: ReyaTradingClient, - spot_rest_2: ReyaTradingClient | None, - qty: Decimal, -) -> None: - print("\n=== Error flows ===") +# ---- Error-path tests ------------------------------------------------------ + + +async def test_err_duplicate_request_id(harness): # pylint: disable=redefined-outer-name + """E1: two createOrder frames with the same envelope id -> DUPLICATE_REQUEST_ID.""" + await flow_err_duplicate_request_id(harness.ws_url, harness.spot_rest, qty=harness.spot_qty) + + +async def test_err_malformed_json(harness): # pylint: disable=redefined-outer-name + """E2: non-JSON frame -> MALFORMED_JSON.""" + flow_err_malformed_json(harness.ws_url) - print("\n--- Flow E1: DUPLICATE_REQUEST_ID ---") - await flow_err_duplicate_request_id(ws_url, spot_rest, qty=qty) - print("\n--- Flow E2: MALFORMED_JSON ---") - flow_err_malformed_json(ws_url) +async def test_err_unknown_type(harness): # pylint: disable=redefined-outer-name + """E3: unknown envelope type -> UNKNOWN_TYPE.""" + flow_err_unknown_type(harness.ws_url) - print("\n--- Flow E3: UNKNOWN_TYPE ---") - flow_err_unknown_type(ws_url) - print("\n--- Flow E4: INVALID_NONCE_ERROR ---") - await flow_err_invalid_nonce(ws_url, spot_rest, qty=qty) +async def test_err_invalid_nonce(harness): # pylint: disable=redefined-outer-name + """E4: replayed nonce -> INVALID_NONCE_ERROR.""" + await flow_err_invalid_nonce(harness.ws_url, harness.spot_rest, qty=harness.spot_qty) - print("\n--- Flow E5: ORDER_DEADLINE_PASSED_ERROR ---") - flow_err_order_deadline_passed(ws_url, spot_rest, qty=qty) - if spot_rest_2 is not None: - print("\n--- Flow E6: UNAUTHORIZED_SIGNATURE_ERROR ---") - flow_err_unauthorized_signature(ws_url, spot_rest, spot_rest_2, qty=qty) - else: - print("\n--- Flow E6: UNAUTHORIZED_SIGNATURE_ERROR (skipped: SPOT_*_2 not set in .env) ---") +async def test_err_order_deadline_passed(harness): # pylint: disable=redefined-outer-name + """E5: past expiresAfter/deadline -> ORDER_DEADLINE_PASSED_ERROR / INPUT_VALIDATION_ERROR.""" + flow_err_order_deadline_passed(harness.ws_url, harness.spot_rest, qty=harness.spot_qty) -if __name__ == "__main__": - sys.exit(asyncio.run(main())) +async def test_err_unauthorized_signature(harness): # pylint: disable=redefined-outer-name + """E6: declared signerWallet != recovered signer -> rejected. Requires SPOT_*_2.""" + if harness.spot_rest_2 is None: + pytest.skip("SPOT_*_2 not set in .env; signer-mismatch test needs a second account") + flow_err_unauthorized_signature(harness.ws_url, harness.spot_rest, harness.spot_rest_2, qty=harness.spot_qty)