diff --git a/.env.example b/.env.example index 92fff55c..6fdd465d 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,29 @@ -# Required for consuming data -REYA_WS_URL= +### Cronos (testnet) +CHAIN_ID=89346162 +REYA_WS_URL="wss://websocket-testnet.reya.xyz/" +REYA_API_URL="https://api-cronos.reya.xyz/v2" -# Required for running actions on Reya DEX -PRIVATE_KEY= -ACCOUNT_ID= -CHAIN_ID= # 1729 mainnet and 89346162 cronos +### Reya Network (mainnet) +#CHAIN_ID=1729 +#REYA_WS_URL="wss://ws.reya.xyz/" +#REYA_API_URL="https://api.reya.xyz/v2" -# The wallet address that owns ACCOUNT_ID -OWNER_WALLET_ADDRESS= +### Staging (uses mainnet chain ID 1729) +#CHAIN_ID=1729 +#REYA_WS_URL="wss://websocket-staging.reya.xyz" +#REYA_API_URL="https://api-staging.reya.xyz/v2" -### Reya Cronos (testnet) example -ACCOUNT_ID=replaceme -PRIVATE_KEY=replacemereplacemereplacemereplacemereplacemereplacemereplaceme -CHAIN_ID=89346162 -REYA_WS_URL="wss://websocket-testnet.reya.xyz/" -OWMNER_WALLET_ADDRESS=replaceme +# PERP_ACCOUNT_ID_1 +PERP_ACCOUNT_ID_1= +PERP_PRIVATE_KEY_1= +PERP_WALLET_ADDRESS_1= + +# SPOT_ACCOUNT_ID_1 +SPOT_ACCOUNT_ID_1= +SPOT_PRIVATE_KEY_1= +SPOT_WALLET_ADDRESS_1= -### Reya Network (mainnet) example -ACCOUNT_ID=replaceme -PRIVATE_KEY=replacemereplacemereplacemereplacemereplacemereplacemereplaceme -CHAIN_ID=1729 -REYA_WS_URL="wss://ws.reya.xyz/" -OWNER_WALLET_ADDRESS=replaceme +# SPOT_ACCOUNT_ID_2 +SPOT_ACCOUNT_ID_2= +SPOT_PRIVATE_KEY_2= +SPOT_WALLET_ADDRESS_2= diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 7b4e1df8..1e63b1c2 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -8,6 +8,10 @@ on: - pyproject.toml - sdk/_version.py +env: + CI_COMMIT_AUTHOR: ${{ github.event.repository.name }} Reya Bot + CI_COMMIT_EMAIL: devcold@voltz.xyz + jobs: create-release: runs-on: ubuntu-latest @@ -76,8 +80,8 @@ jobs: COMMIT_SHA="${{ github.sha }}" # Create annotated tag with v prefix - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" + git config --local user.email "${{ env.CI_COMMIT_EMAIL }}" + git config --local user.name "${{ env.CI_COMMIT_AUTHOR }}" git tag -a "v$VERSION" -m "Release version v$VERSION Auto-tagged after merge to main branch. diff --git a/.github/workflows/version-consistency.yml b/.github/workflows/version-consistency.yml index b2bbbae4..6ca3148c 100644 --- a/.github/workflows/version-consistency.yml +++ b/.github/workflows/version-consistency.yml @@ -6,6 +6,10 @@ on: pull_request: branches: [main, develop] +env: + CI_COMMIT_AUTHOR: ${{ github.event.repository.name }} Reya Bot + CI_COMMIT_EMAIL: devcold@voltz.xyz + jobs: version-consistency: runs-on: ubuntu-latest @@ -107,11 +111,29 @@ jobs: BASE_SHA=$(git merge-base HEAD origin/${{ github.base_ref }}) if git diff $BASE_SHA HEAD -- pyproject.toml | grep -q '^\+.*version = '; then - echo "VERSION_MANUALLY_MODIFIED=true" >> $GITHUB_ENV - echo "Version was manually modified in this PR" + echo "Version change detected in pyproject.toml" + + # Check if the version change was made by the CI bot (auto-commit) + # Get the commit that last modified the version line in pyproject.toml + LAST_VERSION_COMMIT=$(git log --oneline -1 --format="%H" -- pyproject.toml) + LAST_VERSION_AUTHOR=$(git log --oneline -1 --format="%an" -- pyproject.toml) + LAST_VERSION_MESSAGE=$(git log --oneline -1 --format="%s" -- pyproject.toml) + + echo "Last version commit: $LAST_VERSION_COMMIT" + echo "Last version author: $LAST_VERSION_AUTHOR" + echo "Last version message: $LAST_VERSION_MESSAGE" + + # Check if it was an auto-commit by the CI bot + if [[ "$LAST_VERSION_MESSAGE" == "chore: bump SDK version to"* ]] && [[ "$LAST_VERSION_AUTHOR" == *"Reya Bot"* ]]; then + echo "VERSION_MANUALLY_MODIFIED=false" >> $GITHUB_ENV + echo "Version was modified by CI bot (auto-commit), not manually" + else + echo "VERSION_MANUALLY_MODIFIED=true" >> $GITHUB_ENV + echo "Version was manually modified in this PR" + fi else echo "VERSION_MANUALLY_MODIFIED=false" >> $GITHUB_ENV - echo "Version was not manually modified in this PR" + echo "Version was not modified in this PR" fi - name: Extract version components @@ -197,8 +219,8 @@ jobs: - name: Commit version bump if: github.event_name == 'pull_request' && env.NEW_VERSION != '' run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" + git config --local user.email "${{ env.CI_COMMIT_EMAIL }}" + git config --local user.name "${{ env.CI_COMMIT_AUTHOR }}" git add pyproject.toml git commit -m "chore: bump SDK version to $NEW_VERSION" git push origin HEAD:${{ github.head_ref }} diff --git a/.openapi-generator/FILES b/.openapi-generator/FILES index 91fe7573..b5330148 100644 --- a/.openapi-generator/FILES +++ b/.openapi-generator/FILES @@ -13,18 +13,24 @@ sdk/open_api/exceptions.py sdk/open_api/models/__init__.py sdk/open_api/models/account.py sdk/open_api/models/account_balance.py +sdk/open_api/models/account_type.py sdk/open_api/models/asset_definition.py sdk/open_api/models/cancel_order_request.py sdk/open_api/models/cancel_order_response.py sdk/open_api/models/candle_history_data.py 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_type.py sdk/open_api/models/fee_tier_parameters.py sdk/open_api/models/global_fee_parameters.py +sdk/open_api/models/level.py sdk/open_api/models/liquidity_parameters.py sdk/open_api/models/market_definition.py sdk/open_api/models/market_summary.py +sdk/open_api/models/mass_cancel_request.py +sdk/open_api/models/mass_cancel_response.py sdk/open_api/models/order.py sdk/open_api/models/order_status.py sdk/open_api/models/order_type.py @@ -40,6 +46,7 @@ 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_list.py +sdk/open_api/models/spot_market_definition.py sdk/open_api/models/tier_type.py sdk/open_api/models/time_in_force.py sdk/open_api/models/wallet_configuration.py diff --git a/examples/rest_api/perps/__init__.py b/examples/rest_api/perps/__init__.py new file mode 100644 index 00000000..e30681c9 --- /dev/null +++ b/examples/rest_api/perps/__init__.py @@ -0,0 +1,6 @@ +""" +REST API examples for perpetual (perps) trading. + +These examples demonstrate how to interact with the Reya Trading API +for perpetual futures markets (e.g., ETHRUSDPERP, BTCRUSDPERP). +""" diff --git a/examples/rest_api/account_info.py b/examples/rest_api/perps/account_info.py similarity index 87% rename from examples/rest_api/account_info.py rename to examples/rest_api/perps/account_info.py index e906ccf0..6b3adefa 100644 --- a/examples/rest_api/account_info.py +++ b/examples/rest_api/perps/account_info.py @@ -2,9 +2,13 @@ """ Example script showing how to get accounts for a wallet address using the Reya Trading SDK. -Before running this example, ensure you have a .env file with the following variables: -- PRIVATE_KEY: Your Ethereum private key +Requirements: - CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_PRIVATE_KEY_1: Your Ethereum private key +- PERP_WALLET_ADDRESS_1: Your wallet address + +Usage: + python -m examples.rest_api.perps.account_info """ import asyncio diff --git a/examples/rest_api/assets_example.py b/examples/rest_api/perps/assets_example.py similarity index 80% rename from examples/rest_api/assets_example.py rename to examples/rest_api/perps/assets_example.py index 019bb393..81b0b5ff 100644 --- a/examples/rest_api/assets_example.py +++ b/examples/rest_api/perps/assets_example.py @@ -2,8 +2,12 @@ """ Example script showing how to get assets information using the Reya Trading SDK. -Before running this example, ensure you have a .env file with the following variables: -- API_URL: (optional) The API URL to use +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_WALLET_ADDRESS_1: Your wallet address + +Usage: + python -m examples.rest_api.perps.assets_example """ import asyncio diff --git a/examples/rest_api/markets_example.py b/examples/rest_api/perps/markets_example.py similarity index 87% rename from examples/rest_api/markets_example.py rename to examples/rest_api/perps/markets_example.py index 6327a231..09a38e29 100644 --- a/examples/rest_api/markets_example.py +++ b/examples/rest_api/perps/markets_example.py @@ -2,8 +2,12 @@ """ Example script showing how to get markets information using the Reya Trading SDK. -Before running this example, ensure you have a .env file with the following variables: -- API_URL: (optional) The API URL to use +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_WALLET_ADDRESS_1: Your wallet address + +Usage: + python -m examples.rest_api.perps.markets_example """ import asyncio diff --git a/examples/rest_api/order_entry.py b/examples/rest_api/perps/order_entry.py similarity index 97% rename from examples/rest_api/order_entry.py rename to examples/rest_api/perps/order_entry.py index 6e6039fd..d7a23e09 100644 --- a/examples/rest_api/order_entry.py +++ b/examples/rest_api/perps/order_entry.py @@ -9,11 +9,14 @@ - Take Profit (TP) Orders - Order Cancellation -Before running this example, ensure you have a .env file with the following variables: -- PRIVATE_KEY: Your Ethereum private key -- ACCOUNT_ID: Your Reya account ID +Requirements: - CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) -- API_URL: The API URL (optional, defaults based on chain ID) +- PERP_ACCOUNT_ID_1: Your Reya account ID +- PERP_PRIVATE_KEY_1: Your Ethereum private key +- PERP_WALLET_ADDRESS_1: Your wallet address + +Usage: + python -m examples.rest_api.perps.order_entry """ import asyncio import logging @@ -239,7 +242,7 @@ async def main(): load_dotenv() # Verify required environment variables - required_vars = ["PRIVATE_KEY", "ACCOUNT_ID"] + required_vars = ["PERP_PRIVATE_KEY_1", "PERP_ACCOUNT_ID_1", "PERP_WALLET_ADDRESS_1"] missing_vars = [var for var in required_vars if not os.getenv(var)] if missing_vars: diff --git a/examples/rest_api/prices_example.py b/examples/rest_api/perps/prices_example.py similarity index 85% rename from examples/rest_api/prices_example.py rename to examples/rest_api/perps/prices_example.py index f1352b09..87df6f47 100644 --- a/examples/rest_api/prices_example.py +++ b/examples/rest_api/perps/prices_example.py @@ -1,8 +1,13 @@ #!/usr/bin/env python3 -"""Example script showing how to get price information using the Reya Trading SDK. +""" +Example script showing how to get price information using the Reya Trading SDK. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_WALLET_ADDRESS_1: Your wallet address -Before running this example, ensure you have a .env file with the following variables: -- API_URL: (optional) The API URL to use +Usage: + python -m examples.rest_api.perps.prices_example """ import asyncio diff --git a/examples/rest_api/wallet_example.py b/examples/rest_api/perps/wallet_example.py similarity index 91% rename from examples/rest_api/wallet_example.py rename to examples/rest_api/perps/wallet_example.py index a3d3675e..94e2e76a 100644 --- a/examples/rest_api/wallet_example.py +++ b/examples/rest_api/perps/wallet_example.py @@ -13,10 +13,14 @@ - Getting auto exchange settings - Getting wallet stats -Before running this example, ensure you have a .env file with the following variables: -- PRIVATE_KEY: Your Ethereum private key -- ACCOUNT_ID: Your Reya account ID +Requirements: - CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_ACCOUNT_ID_1: Your Reya account ID +- PERP_PRIVATE_KEY_1: Your Ethereum private key +- PERP_WALLET_ADDRESS_1: Your wallet address + +Usage: + python -m examples.rest_api.perps.wallet_example """ import asyncio diff --git a/examples/rest_api/spot/__init__.py b/examples/rest_api/spot/__init__.py new file mode 100644 index 00000000..5aeae10a --- /dev/null +++ b/examples/rest_api/spot/__init__.py @@ -0,0 +1,6 @@ +""" +REST API examples for spot trading. + +These examples demonstrate how to interact with the Reya Trading API +for spot markets (e.g., WETHRUSD). +""" diff --git a/examples/rest_api/spot/spot_trade.py b/examples/rest_api/spot/spot_trade.py new file mode 100644 index 00000000..a025d351 --- /dev/null +++ b/examples/rest_api/spot/spot_trade.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +""" +Spot Trade - Simple buy and sell example using the SPOT order book. + +This script demonstrates how to: +1. Check account balance (RUSD) +2. Fetch order book depth to determine execution prices +3. Buy 0.001 ETH using an IOC order +4. Sell the 0.001 ETH using an IOC order + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- SPOT_ACCOUNT_ID_1: Your Reya account ID +- SPOT_PRIVATE_KEY_1: Your Ethereum private key + +Usage: + python -m examples.rest_api.spot.spot_trade +""" + +import asyncio +import logging +import sys +from decimal import Decimal + +from dotenv import load_dotenv + +from sdk.open_api.models import TimeInForce +from sdk.reya_rest_api import ReyaTradingClient, get_spot_config +from sdk.reya_rest_api.models.orders import LimitOrderParameters + +# ============================================================================= +# CONFIGURATION +# ============================================================================= + +SPOT_SYMBOL = "WETHRUSD" +TRADE_QTY = "0.001" + +# ============================================================================= +# LOGGING SETUP +# ============================================================================= + +logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger("spot_trade") +logger.setLevel(logging.INFO) +logger.handlers = [] +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) +logger.addHandler(handler) +logger.propagate = False + + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + + +async def get_rusd_balance(client: ReyaTradingClient, account_id: int) -> Decimal: + """Get the RUSD balance for a specific account.""" + balances = await client.get_account_balances() + for balance in balances: + if balance.account_id == account_id and balance.asset == "RUSD": + return Decimal(balance.real_balance) + return Decimal("0") + + +async def get_best_ask_price(client: ReyaTradingClient, symbol: str) -> Decimal | None: + """Get the best ask price from the order book (for buying).""" + depth = await client.markets.get_market_depth(symbol=symbol) + if depth.asks and len(depth.asks) > 0: + return Decimal(depth.asks[0].px) + return None + + +async def get_best_bid_price(client: ReyaTradingClient, symbol: str) -> Decimal | None: + """Get the best bid price from the order book (for selling).""" + depth = await client.markets.get_market_depth(symbol=symbol) + if depth.bids and len(depth.bids) > 0: + return Decimal(depth.bids[0].px) + return None + + +# ============================================================================= +# MAIN TRADING LOGIC +# ============================================================================= + + +async def main() -> None: + """Main entry point for the spot trade example.""" + load_dotenv() + + # Get config from environment (uses SPOT_ACCOUNT_ID_1, SPOT_PRIVATE_KEY_1, SPOT_WALLET_ADDRESS_1) + try: + config = get_spot_config(account_number=1) + except ValueError as e: + logger.error(f"❌ Configuration error: {e}") + sys.exit(1) + + if config.account_id is None: + logger.error("❌ SPOT_ACCOUNT_ID_1 environment variable is required") + sys.exit(1) + + if config.private_key is None: + logger.error("❌ SPOT_PRIVATE_KEY_1 environment variable is required") + sys.exit(1) + + account_id = config.account_id + + logger.info("=" * 60) + logger.info("SPOT TRADE EXAMPLE") + logger.info("=" * 60) + logger.info(f"Symbol: {SPOT_SYMBOL}") + logger.info(f"Quantity: {TRADE_QTY} ETH") + logger.info(f"Account ID: {account_id}") + logger.info("=" * 60) + + # Create trading client + client = ReyaTradingClient(config) + + try: + # Initialize client (loads market definitions) + await client.start() + logger.info("✅ Client initialized") + + # Step 1: Check RUSD balance + rusd_balance = await get_rusd_balance(client, account_id) + logger.info(f"📊 RUSD Balance: {rusd_balance}") + + # Step 2: Fetch order book depth + logger.info(f"📖 Fetching order book for {SPOT_SYMBOL}...") + best_ask = await get_best_ask_price(client, SPOT_SYMBOL) + best_bid = await get_best_bid_price(client, SPOT_SYMBOL) + + if best_ask is None: + logger.error("❌ No asks in order book - cannot buy") + sys.exit(1) + + if best_bid is None: + logger.error("❌ No bids in order book - cannot sell") + sys.exit(1) + + logger.info(f" Best Ask (buy price): ${best_ask}") + logger.info(f" Best Bid (sell price): ${best_bid}") + + # Step 3: Check if we have enough RUSD to buy + trade_qty = Decimal(TRADE_QTY) + required_rusd = trade_qty * best_ask + logger.info(f"💰 Required RUSD for buy: {required_rusd}") + + if rusd_balance < required_rusd: + logger.error(f"❌ Insufficient RUSD balance. Have: {rusd_balance}, Need: {required_rusd}") + sys.exit(1) + + logger.info("✅ Sufficient balance for trade") + + # Step 4: Place IOC BUY order + logger.info("-" * 60) + logger.info(f"📈 Placing IOC BUY order: {TRADE_QTY} ETH @ ${best_ask}") + + buy_params = LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=True, + qty=TRADE_QTY, + limit_px=str(best_ask), + time_in_force=TimeInForce.IOC, + ) + + buy_response = await client.create_limit_order(buy_params) + logger.info(f"✅ BUY order submitted: Order ID = {buy_response.order_id}") + + # Small delay to allow order to settle + await asyncio.sleep(1) + + # Step 5: Place IOC SELL order + # Refresh best bid price as it may have changed + best_bid = await get_best_bid_price(client, SPOT_SYMBOL) + if best_bid is None: + logger.error("❌ No bids in order book - cannot sell") + sys.exit(1) + + logger.info("-" * 60) + logger.info(f"📉 Placing IOC SELL order: {TRADE_QTY} ETH @ ${best_bid}") + + sell_params = LimitOrderParameters( + symbol=SPOT_SYMBOL, + is_buy=False, + qty=TRADE_QTY, + limit_px=str(best_bid), + time_in_force=TimeInForce.IOC, + ) + + sell_response = await client.create_limit_order(sell_params) + logger.info(f"✅ SELL order submitted: Order ID = {sell_response.order_id}") + + # Step 6: Summary + logger.info("=" * 60) + logger.info("✅ TRADE COMPLETE") + logger.info(f" Buy Order ID: {buy_response.order_id}") + logger.info(f" Sell Order ID: {sell_response.order_id}") + logger.info("=" * 60) + + finally: + await client.close() + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nProgram interrupted by user. Exiting...") diff --git a/examples/rest_api/spot/spot_transfer.py b/examples/rest_api/spot/spot_transfer.py new file mode 100644 index 00000000..e46bfd01 --- /dev/null +++ b/examples/rest_api/spot/spot_transfer.py @@ -0,0 +1,493 @@ +#!/usr/bin/env python3 +""" +Spot Asset Transfer - Transfer spot assets between accounts via order matching. + +This script transfers a spot asset (e.g., WETH) from one account to another using +the SPOT API. Since there's no direct transfer mechanism for spot assets, this is +accomplished by matching orders: +1. Sender places a GTC sell order at a specific price +2. Receiver places an IOC buy order at the same price to match + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- SPOT_PRIVATE_KEY_1: Private key for the sender wallet +- SPOT_PRIVATE_KEY_2: Private key for the receiver wallet (defaults to SPOT_PRIVATE_KEY_1 if same wallet) +- Sender account must be whitelisted in ME_GTC_PERMISSIONS_WHITELIST on the API server +- Receiver account must have sufficient rUSD balance to pay for the asset + +Usage: + python -m examples.rest_api.spot.spot_transfer \\ + --from-account 10000000002 \\ + --to-account 10000000003 \\ + --asset ETH \\ + --qty 5 +""" + +import argparse +import asyncio +import logging +import os +import sys +from decimal import Decimal + +from dotenv import load_dotenv +from eth_account import Account + +from sdk.open_api.models 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 + +logging.basicConfig(level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger("spot_transfer") +logger.setLevel(logging.INFO) +logger.handlers = [] +handler = logging.StreamHandler() +handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) +logger.addHandler(handler) +logger.propagate = False + +ASSET_TO_SYMBOL = {"ETH": "WETHRUSD", "WETH": "WETHRUSD"} +DEFAULT_TRANSFER_PRICE = "3156" # Realistic ETH price for transfers +ORDER_SETTLEMENT_RETRIES = 3 +ORDER_SETTLEMENT_DELAY = 1.0 + + +async def get_account_balance(client: ReyaTradingClient, account_id: int, asset: str) -> Decimal: + """Get the balance of a specific asset for a specific account.""" + balances = await client.get_account_balances() + for balance in balances: + if balance.account_id == account_id and balance.asset == asset: + return Decimal(balance.real_balance) + return Decimal("0") + + +async def log_account_balances( + sender_client: ReyaTradingClient, + receiver_client: ReyaTradingClient | None, + from_account_id: int, + to_account_id: int, + asset: str, + label: str, +) -> tuple[Decimal, Decimal]: + """Log and return balances for both accounts. + + Uses sender_client for from_account and receiver_client for to_account + since each client can only see balances for accounts owned by its wallet. + """ + from_balance = await get_account_balance(sender_client, from_account_id, asset) + + # Use receiver client if available, otherwise try sender client + if receiver_client: + to_balance = await get_account_balance(receiver_client, to_account_id, asset) + else: + to_balance = await get_account_balance(sender_client, to_account_id, asset) + + logger.info(f"{label}") + logger.info(f" From Account ({from_account_id}): {from_balance:>12} {asset}") + logger.info(f" To Account ({to_account_id}): {to_balance:>12} {asset}") + + return from_balance, to_balance + + +async def validate_sufficient_balance( + client: ReyaTradingClient, + account_id: int, + asset: str, + required_qty: Decimal, +) -> bool: + """Validate that the account has sufficient balance for the transfer.""" + balance = await get_account_balance(client, account_id, asset) + + if balance < required_qty: + logger.error( + f"❌ Insufficient balance! Account {account_id} has {balance} {asset}, " + f"but {required_qty} {asset} is required for transfer." + ) + return False + + logger.info(f" ✅ Balance check passed: {balance} {asset} >= {required_qty} {asset}") + return True + + +async def execute_spot_transfer( + sender_client: ReyaTradingClient, + receiver_client: ReyaTradingClient, + from_account_id: int, + to_account_id: int, + symbol: str, + qty: str, + transfer_price: str, +) -> tuple[bool, str | None]: + """ + Execute a spot transfer by matching orders between sender and receiver. + + The sender places a GTC sell order, and the receiver places an IOC buy order + at the same price to match immediately. + """ + logger.info("📤 Executing transfer...") + + # Step 1: Sender places GTC sell order + logger.info(f" [1/3] Placing sell order from Account {from_account_id}") + + sell_params = LimitOrderParameters( + symbol=symbol, + is_buy=False, + limit_px=transfer_price, + qty=qty, + time_in_force=TimeInForce.GTC, + ) + + sell_response = await sender_client.create_limit_order(sell_params) + sell_order_id = sell_response.order_id + + if not sell_order_id: + logger.error(f"❌ Failed to create sell order: {sell_response.status}") + return False, None + + logger.info(f" ✓ Order created: {sell_order_id}") + + await asyncio.sleep(0.5) + + # Step 2: Receiver places IOC buy order to match + logger.info(f" [2/3] Placing buy order from Account {to_account_id}") + + buy_params = LimitOrderParameters( + symbol=symbol, + is_buy=True, + limit_px=transfer_price, + qty=qty, + time_in_force=TimeInForce.IOC, + ) + + buy_response = await receiver_client.create_limit_order(buy_params) + buy_order_id = buy_response.order_id + + logger.info(f" ✓ Order filled: {buy_response.status.value}") + + # Step 3: Wait for orders to settle + logger.info(" [3/3] Settling orders...") + await asyncio.sleep(2.0) + + # Check if sell order is still open + order_fully_matched = False + open_orders = await sender_client.get_open_orders() + sell_order_still_open = any(order.order_id == sell_order_id for order in open_orders if hasattr(order, "order_id")) + + if sell_order_still_open: + logger.warning(f" ⚠️ Sell order {sell_order_id} partially filled, cancelling remainder...") + try: + await sender_client.cancel_order( + order_id=sell_order_id, + symbol=symbol, + account_id=from_account_id, + ) + except (OSError, RuntimeError): # nosec B110 + pass # Order may have been filled in the meantime + else: + order_fully_matched = True + logger.info(" ✓ Sell order fully matched") + + # Get transaction hash from spot executions + tx_hash = None + try: + spot_executions = await receiver_client.get_spot_executions() + for execution in spot_executions.data: + if execution.order_id == buy_order_id: + tx_hash = execution.additional_properties.get("transactionHash") + if not tx_hash: + tx_hash = execution.additional_properties.get("txHash") + break + except (OSError, RuntimeError): # nosec B110 + pass # Execution lookup may fail, but transfer still succeeded + + return order_fully_matched, tx_hash + + +def create_trading_client_config( + private_key: str, + account_id: int, + base_config: TradingConfig, +) -> TradingConfig: + """Create a TradingConfig for a specific account and private key.""" + wallet = Account.from_key(private_key) + return TradingConfig( + private_key=private_key, + chain_id=base_config.chain_id, + api_url=base_config.api_url, + account_id=account_id, + owner_wallet_address=wallet.address, + ) + + +async def balance_accounts_mode() -> None: + """Balance ETH and RUSD between SPOT_ACCOUNT_1 and SPOT_ACCOUNT_2.""" + load_dotenv() + + spot_account_1 = int(os.getenv("SPOT_ACCOUNT_ID_1", "0")) + spot_account_2 = int(os.getenv("SPOT_ACCOUNT_ID_2", "0")) + spot_key_1 = os.getenv("SPOT_PRIVATE_KEY_1") + spot_key_2 = os.getenv("SPOT_PRIVATE_KEY_2") + + if not spot_account_1 or not spot_account_2: + logger.error("❌ SPOT_ACCOUNT_ID_1 and SPOT_ACCOUNT_ID_2 must be set") + sys.exit(1) + + if not spot_key_1: + logger.error("❌ SPOT_PRIVATE_KEY_1 must be set") + sys.exit(1) + + if not spot_key_2: + logger.error("❌ SPOT_PRIVATE_KEY_2 must be set") + sys.exit(1) + + logger.info("⚖️ BALANCE ACCOUNTS MODE") + logger.info(f" Account 1: {spot_account_1}") + logger.info(f" Account 2: {spot_account_2}") + + # Get base config to inherit api_url and chain_id + base_client = ReyaTradingClient() + base_config = base_client.config + + # Create client 1 with proper config + config_1 = create_trading_client_config(spot_key_1, spot_account_1, base_config) + async with ReyaTradingClient(config=config_1) as client_1: + await client_1.start() + + # Create client 2 with proper config + config_2 = create_trading_client_config(spot_key_2, spot_account_2, base_config) + async with ReyaTradingClient(config=config_2) as client_2: + await client_2.start() + + # Get current balances + eth_1 = await get_account_balance(client_1, spot_account_1, "ETH") + eth_2 = await get_account_balance(client_2, spot_account_2, "ETH") + rusd_1 = await get_account_balance(client_1, spot_account_1, "RUSD") + rusd_2 = await get_account_balance(client_2, spot_account_2, "RUSD") + + logger.info("📊 CURRENT BALANCES") + logger.info(f" Account {spot_account_1}: {eth_1} ETH, {rusd_1} RUSD") + logger.info(f" Account {spot_account_2}: {eth_2} ETH, {rusd_2} RUSD") + + # Calculate targets + total_eth = eth_1 + eth_2 + total_rusd = rusd_1 + rusd_2 + target_eth = total_eth / 2 + _ = total_rusd / 2 # target_rusd - calculated but not used directly + + logger.info("🎯 TARGET ETH BALANCE") + logger.info(f" Each account: {target_eth} ETH") + logger.info(f" (RUSD will flow at market price ${DEFAULT_TRANSFER_PRICE})") + + # Determine transfer direction and amounts + eth_diff_1 = eth_1 - target_eth # Positive = account 1 has excess + + # We transfer ETH from the account with excess + # Note: We use the market price (DEFAULT_TRANSFER_PRICE) for the transfer. + # This balances ETH between accounts but RUSD will flow at market price, + # not at an arbitrary price to balance both assets simultaneously. + # On-chain price validation enforces orders must be within ~5% of oracle price. + if abs(eth_diff_1) < Decimal("0.001"): + logger.info("✅ Accounts already balanced (ETH difference < 0.001)") + return + + if eth_diff_1 > 0: + # Account 1 has excess ETH, transfer to Account 2 + from_account = spot_account_1 + to_account = spot_account_2 + sender_client = client_1 + receiver_client = client_2 + eth_to_transfer = eth_diff_1 + else: + # Account 2 has excess ETH, transfer to Account 1 + from_account = spot_account_2 + to_account = spot_account_1 + sender_client = client_2 + receiver_client = client_1 + eth_to_transfer = -eth_diff_1 + + # Use market price for transfer (on-chain enforces price limits) + transfer_price = Decimal(DEFAULT_TRANSFER_PRICE) + + # Round to reasonable precision + eth_qty = str(eth_to_transfer.quantize(Decimal("0.001"))) + price_str = str(transfer_price.quantize(Decimal("0.01"))) + + logger.info("📤 TRANSFER PLAN") + logger.info(f" From: {from_account} → To: {to_account}") + logger.info(f" ETH: {eth_qty}") + logger.info(f" Price: ${price_str} (RUSD per ETH)") + + # Execute transfer + symbol = "WETHRUSD" + _, tx_hash = await execute_spot_transfer( + sender_client, receiver_client, from_account, to_account, symbol, eth_qty, price_str + ) + + if tx_hash: + logger.info(f"🔗 Transaction: {tx_hash}") + + # Wait and show final balances + logger.info("⏳ Waiting for balance updates...") + await asyncio.sleep(2.0) + + eth_1_final = await get_account_balance(client_1, spot_account_1, "ETH") + eth_2_final = await get_account_balance(client_2, spot_account_2, "ETH") + rusd_1_final = await get_account_balance(client_1, spot_account_1, "RUSD") + rusd_2_final = await get_account_balance(client_2, spot_account_2, "RUSD") + + logger.info("📊 FINAL BALANCES") + logger.info(f" Account {spot_account_1}: {eth_1_final} ETH, {rusd_1_final} RUSD") + logger.info(f" Account {spot_account_2}: {eth_2_final} ETH, {rusd_2_final} RUSD") + logger.info("🎉 Accounts balanced!") + + +async def main(): + """Main entry point for the spot transfer script.""" + parser = argparse.ArgumentParser(description="Transfer spot assets between accounts via order matching") + parser.add_argument( + "--balance-accounts", action="store_true", help="Balance ETH and RUSD between SPOT_1 and SPOT_2 accounts" + ) + parser.add_argument("--from-account", type=int, help="Account ID to transfer FROM (sender)") + parser.add_argument("--to-account", type=int, help="Account ID to transfer TO (receiver)") + parser.add_argument("--asset", type=str, choices=["ETH", "WETH"], help="Asset to transfer") + parser.add_argument("--qty", type=str, help="Quantity to transfer (e.g., 5)") + parser.add_argument("--price", type=str, default=None, help="Custom price for transfer (default: 0.01)") + + args = parser.parse_args() + + if args.balance_accounts: + await balance_accounts_mode() + return + + # Validate required args for manual transfer mode + if not all([args.from_account, args.to_account, args.asset, args.qty]): + parser.error("--from-account, --to-account, --asset, and --qty are required for manual transfer") + + load_dotenv() + + from_account_id = args.from_account + to_account_id = args.to_account + asset = args.asset.upper() + qty = args.qty + transfer_price = args.price if args.price else DEFAULT_TRANSFER_PRICE + + symbol = ASSET_TO_SYMBOL.get(asset) + if not symbol: + logger.error(f"❌ Unknown asset: {asset}. Supported: {list(ASSET_TO_SYMBOL.keys())}") + sys.exit(1) + + # Map account IDs to their private keys + spot_account_1 = int(os.getenv("SPOT_ACCOUNT_ID_1", "0")) + spot_account_2 = int(os.getenv("SPOT_ACCOUNT_ID_2", "0")) + spot_key_1 = os.getenv("SPOT_PRIVATE_KEY_1") + spot_key_2 = os.getenv("SPOT_PRIVATE_KEY_2") + + if not spot_key_1: + logger.error("❌ SPOT_PRIVATE_KEY_1 environment variable is required") + sys.exit(1) + + # Determine which key to use for sender and receiver based on account ownership + sender_key: str | None = None + receiver_key: str | None = None + + if from_account_id == spot_account_1: + sender_key = spot_key_1 + elif from_account_id == spot_account_2: + sender_key = spot_key_2 + else: + logger.error(f"❌ Unknown from_account {from_account_id}. Must be {spot_account_1} or {spot_account_2}") + sys.exit(1) + + if to_account_id == spot_account_1: + receiver_key = spot_key_1 + elif to_account_id == spot_account_2: + receiver_key = spot_key_2 + else: + logger.error(f"❌ Unknown to_account {to_account_id}. Must be {spot_account_1} or {spot_account_2}") + sys.exit(1) + + if not sender_key: + logger.error(f"❌ Private key not configured for sender account {from_account_id}") + sys.exit(1) + if not receiver_key: + logger.error(f"❌ Private key not configured for receiver account {to_account_id}") + sys.exit(1) + + logger.info("🚀 SPOT ASSET TRANSFER") + logger.info(f" From Account: {from_account_id}") + logger.info(f" To Account: {to_account_id}") + logger.info(f" Asset: {asset}") + logger.info(f" Quantity: {qty}") + logger.info(f" Symbol: {symbol}") + logger.info(f" Price: ${transfer_price}") + + # Get base config to inherit api_url and chain_id + base_client = ReyaTradingClient() + base_config = base_client.config + + # Create sender client with proper config + sender_config = create_trading_client_config(sender_key, from_account_id, base_config) + async with ReyaTradingClient(config=sender_config) as sender_client: + await sender_client.start() + + # Validate sender has sufficient balance + qty_decimal = Decimal(qty) + if not await validate_sufficient_balance(sender_client, from_account_id, asset, qty_decimal): + sys.exit(1) + + # Create receiver client with proper config + receiver_config = create_trading_client_config(receiver_key, to_account_id, base_config) + async with ReyaTradingClient(config=receiver_config) as receiver_client: + await receiver_client.start() + + # Log initial balances (now both clients available) + initial_from, initial_to = await log_account_balances( + sender_client, receiver_client, from_account_id, to_account_id, asset, "📊 INITIAL BALANCES" + ) + + # Execute the transfer + order_fully_matched, tx_hash = await execute_spot_transfer( + sender_client, receiver_client, from_account_id, to_account_id, symbol, qty, transfer_price + ) + + if tx_hash: + logger.info(f"🔗 Transaction: {tx_hash}") + + # Wait for balances to update + logger.info("⏳ Waiting for balance updates...") + await asyncio.sleep(2.0) + + # Log final balances (use both clients) + final_from, final_to = await log_account_balances( + sender_client, receiver_client, from_account_id, to_account_id, asset, "📊 FINAL BALANCES" + ) + + # Verify the transfer using balance changes + from_change = final_from - initial_from + to_change = final_to - initial_to + + logger.info("📈 Balance changes:") + logger.info(f" From Account ({from_account_id}): {from_change:>+12} {asset}") + logger.info(f" To Account ({to_account_id}): {to_change:>+12} {asset}") + + # Determine success based on both order settlement and balance changes + expected_change = qty_decimal + balances_match = from_change == -expected_change and to_change == expected_change + + if order_fully_matched and balances_match: + logger.info("🎉 Transfer complete - verified successfully!") + elif balances_match: + logger.info("🎉 Transfer complete - balances verified (order was partially filled by stale orders)") + elif to_change > 0: + logger.warning(f"⚠️ Partial transfer. Expected: {expected_change} {asset}, Got: {to_change} {asset}") + sys.exit(1) + else: + logger.error( + f"❌ Transfer failed. Expected: -{expected_change}/{expected_change}, Got: {from_change}/{to_change}" + ) + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/rpc/__init__.py b/examples/rpc/__init__.py new file mode 100644 index 00000000..dba373cc --- /dev/null +++ b/examples/rpc/__init__.py @@ -0,0 +1,10 @@ +""" +RPC examples for perpetual (perps) trading. + +These examples demonstrate how to interact with the Reya blockchain +via Web3 RPC for perpetual futures operations including: +- Trade execution +- Bridging funds +- Margin account management +- Oracle price updates +""" diff --git a/examples/rpc/bridge_in_and_deposit.py b/examples/rpc/bridge_in_and_deposit.py index 7bf1f6d7..15c820cf 100644 --- a/examples/rpc/bridge_in_and_deposit.py +++ b/examples/rpc/bridge_in_and_deposit.py @@ -1,3 +1,18 @@ +""" +Bridge In and Deposit - Bridge USDC from Arbitrum and deposit to margin account. + +This script demonstrates how to bridge USDC into Reya Network from Arbitrum +and optionally deposit it into a margin account once transfer is completed. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_ACCOUNT_ID_1: Your Reya margin account ID +- PERP_PRIVATE_KEY_1: Your Ethereum private key + +Usage: + python -m examples.rpc.bridge_in_and_deposit +""" + import os from dotenv import load_dotenv @@ -6,16 +21,13 @@ def main(): - """ - Example script demonstrating how to bridge USDC into Reya Network from Arbitrum - and optionally deposit it into a margin account once transfer is completed. - """ + """Execute bridge in and deposit operation.""" # Load environment variables from a .env file load_dotenv() # Retrieve the margin account ID from environment variables - account_id = int(os.environ["ACCOUNT_ID"]) + account_id = int(os.environ["PERP_ACCOUNT_ID_1"]) # Load configuration config = get_config() diff --git a/examples/rpc/ma_funds_flow.py b/examples/rpc/ma_funds_flow.py index 33ab4a8d..96064e80 100644 --- a/examples/rpc/ma_funds_flow.py +++ b/examples/rpc/ma_funds_flow.py @@ -1,3 +1,17 @@ +""" +Margin Account Funds Flow - Create accounts, deposit, transfer, and withdraw funds. + +This script demonstrates the full flow of creating margin accounts, +depositing rUSD, transferring funds between accounts, and withdrawing funds. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_PRIVATE_KEY_1: Your Ethereum private key + +Usage: + python -m examples.rpc.ma_funds_flow +""" + from sdk.reya_rpc import ( DepositParams, TransferParams, @@ -11,10 +25,7 @@ def main(): - """ - Example script demonstrating the full flow of creating margin accounts, - depositing rUSD, transferring funds between accounts, and withdrawing funds. - """ + """Execute margin account funds flow example.""" # Load configuration config = get_config() diff --git a/examples/rpc/passive_pool_funds_flow.py b/examples/rpc/passive_pool_funds_flow.py index 097f95a9..c4cebcd8 100644 --- a/examples/rpc/passive_pool_funds_flow.py +++ b/examples/rpc/passive_pool_funds_flow.py @@ -1,11 +1,22 @@ +""" +Passive Pool Funds Flow - Stake and unstake rUSD in the passive pool. + +This script demonstrates how to stake rUSD into the passive pool +and subsequently unstake to retrieve rUSD. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_PRIVATE_KEY_1: Your Ethereum private key + +Usage: + python -m examples.rpc.passive_pool_funds_flow +""" + from sdk.reya_rpc import StakingParams, UnstakingParams, get_config, stake, unstake def main(): - """ - Example script demonstrating how to stake rUSD into the passive pool - and subsequently unstake to retrieve rUSD. - """ + """Execute passive pool staking and unstaking example.""" # Load configuration config = get_config() diff --git a/examples/rpc/rusd_cross_wallet_transfer.py b/examples/rpc/rusd_cross_wallet_transfer.py new file mode 100644 index 00000000..3d751870 --- /dev/null +++ b/examples/rpc/rusd_cross_wallet_transfer.py @@ -0,0 +1,161 @@ +""" +Cross-wallet rUSD Transfer - Transfer rUSD between wallets. + +Transfers rUSD from one margin account to another owned by a DIFFERENT wallet. + +Flow: +1. Withdraw rUSD from source account to source wallet (Wallet A) +2. ERC20 transfer rUSD tokens from Wallet A to Wallet B +3. Deposit rUSD from Wallet B into destination account + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_PRIVATE_KEY_1: Private key for Wallet A (source wallet) +- PERP_PRIVATE_KEY_2: Private key for Wallet B (destination wallet) + +Usage: + python -m examples.rpc.rusd_cross_wallet_transfer +""" + +import os + +from dotenv import load_dotenv +from eth_abi import encode + +from sdk.reya_rpc import WithdrawParams, get_config, withdraw +from sdk.reya_rpc.types import CommandType + + +def main(): + """Execute full cross-wallet rUSD transfer.""" + + load_dotenv() + + # Configuration + from_account_id = 8044 # Source account (owned by Wallet A) + to_account_id = 10000000003 # Destination SPOT account (owned by Wallet B) + to_wallet_address = "0xfCf3AdeE1CfE963ff270CCdf66AB1C8cdc659C36" # Wallet B + amount_rusd = 1 # Amount to transfer (1 rUSD is enough for 5 ETH at 0.01 price) + + # Amount scaled by 10^6 (rUSD has 6 decimals) + amount_e6 = int(amount_rusd * 1e6) + + print("=" * 60) + print("CROSS-WALLET rUSD TRANSFER") + print("=" * 60) + print(f" Amount: {amount_rusd} rUSD") + print(f" From Account: {from_account_id}") + print(f" To Account: {to_account_id}") + print(f" To Wallet: {to_wallet_address}") + print("=" * 60) + + # ========================================================================= + # STEP 1: Withdraw rUSD from source account to Wallet A + # ========================================================================= + print("\n[Step 1/3] Withdrawing rUSD from margin account to Wallet A...") + + config_a = get_config() # Uses PRIVATE_KEY from .env (Wallet A) + wallet_a = config_a["w3account"].address + + print(f" From Account {from_account_id} → Wallet A ({wallet_a})") + + result = withdraw(config_a, WithdrawParams(account_id=from_account_id, amount=amount_e6)) + tx_hash = result["transaction_receipt"]["transactionHash"].hex() + print(f" ✓ Withdrawn: {tx_hash}") + + # ========================================================================= + # STEP 2: ERC20 transfer rUSD from Wallet A to Wallet B + # ========================================================================= + print("\n[Step 2/3] Transferring rUSD tokens from Wallet A to Wallet B...") + + w3 = config_a["w3"] + rusd = config_a["w3contracts"]["rusd"] + + # Check balance + balance = rusd.functions.balanceOf(wallet_a).call() + print(f" Wallet A balance: {balance / 1e6} rUSD") + + if balance < amount_e6: + print(f" ❌ Insufficient balance! Need {amount_e6 / 1e6} rUSD") + return + + # Transfer tokens (must sign manually since RPC node doesn't have private key) + print(f" Wallet A ({wallet_a}) → Wallet B ({to_wallet_address})") + account_a = config_a["w3account"] + tx = rusd.functions.transfer(to_wallet_address, amount_e6).build_transaction( + { + "from": account_a.address, + "nonce": w3.eth.get_transaction_count(account_a.address), + "chainId": config_a["chain_id"], + } + ) + signed_tx = w3.eth.account.sign_transaction(tx, private_key=account_a.key) + tx_hash = w3.eth.send_raw_transaction(signed_tx.raw_transaction) + tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash) + print(f" ✓ Transferred: {tx_receipt['transactionHash'].hex()}") + + # ========================================================================= + # STEP 3: Deposit rUSD from Wallet B into destination account + # ========================================================================= + print("\n[Step 3/3] Depositing rUSD from Wallet B into margin account...") + + # Get Wallet B's private key + private_key_2 = os.environ.get("PERP_PRIVATE_KEY_2") + if not private_key_2: + print(" ❌ PERP_PRIVATE_KEY_2 not set in .env!") + print(" Please add: PERP_PRIVATE_KEY_2=") + print(" Then run the deposit manually or re-run this script.") + return + + # Create config for Wallet B + os.environ["PERP_PRIVATE_KEY_1"] = private_key_2 + config_b = get_config() + account_b = config_b["w3account"] + w3_b = config_b["w3"] + core_b = config_b["w3contracts"]["core"] + rusd_b = config_b["w3contracts"]["rusd"] + + print(f" Wallet B ({account_b.address}) → Account {to_account_id}") + + # Step 3a: Approve rUSD to core contract + print(" Approving rUSD...") + approve_tx = rusd_b.functions.approve(core_b.address, amount_e6).build_transaction( + { + "from": account_b.address, + "nonce": w3_b.eth.get_transaction_count(account_b.address), + "chainId": config_b["chain_id"], + } + ) + signed_approve = w3_b.eth.account.sign_transaction(approve_tx, private_key=account_b.key) + approve_hash = w3_b.eth.send_raw_transaction(signed_approve.raw_transaction) + w3_b.eth.wait_for_transaction_receipt(approve_hash) + print(f" ✓ Approved: {approve_hash.hex()}") + + # Step 3b: Deposit into margin account + inputs_encoded = encode(["(address,uint256)"], [[rusd_b.address, amount_e6]]) + command = (CommandType.Deposit.value, inputs_encoded, 0, 0) + commands = [command] + + deposit_tx = core_b.functions.execute(to_account_id, commands).build_transaction( + { + "from": account_b.address, + "nonce": w3_b.eth.get_transaction_count(account_b.address), + "chainId": config_b["chain_id"], + } + ) + signed_deposit = w3_b.eth.account.sign_transaction(deposit_tx, private_key=account_b.key) + deposit_hash = w3_b.eth.send_raw_transaction(signed_deposit.raw_transaction) + tx_receipt = w3_b.eth.wait_for_transaction_receipt(deposit_hash) + print(f" ✓ Deposited: {tx_receipt['transactionHash'].hex()}") + + # ========================================================================= + # DONE + # ========================================================================= + print("\n" + "=" * 60) + print("✅ TRANSFER COMPLETE!") + print(f" {amount_rusd} rUSD moved from Account {from_account_id} to Account {to_account_id}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/examples/rpc/trade_execution.py b/examples/rpc/trade_execution.py index 72e7bd26..b951b00b 100644 --- a/examples/rpc/trade_execution.py +++ b/examples/rpc/trade_execution.py @@ -1,3 +1,18 @@ +""" +Trade Execution - Execute long and short trades on perpetual markets. + +This script demonstrates how to execute long and short trades on the SOL market +using the RPC layer. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_ACCOUNT_ID_1: Your Reya margin account ID +- PERP_PRIVATE_KEY_1: Your Ethereum private key + +Usage: + python -m examples.rpc.trade_execution +""" + import os from dotenv import load_dotenv @@ -7,15 +22,13 @@ def main(): - """ - Example script demonstrating how to execute long and short trades on SOL market. - """ + """Execute long and short trades on SOL market.""" # Load environment variables from .env file load_dotenv() # Retrieve the margin account ID from environment variables - account_id = int(os.environ["ACCOUNT_ID"]) + account_id = int(os.environ["PERP_ACCOUNT_ID_1"]) # Load configuration config = get_config() diff --git a/examples/rpc/update_oracle_prices.py b/examples/rpc/update_oracle_prices.py index dc374bb8..17a4c279 100644 --- a/examples/rpc/update_oracle_prices.py +++ b/examples/rpc/update_oracle_prices.py @@ -1,11 +1,22 @@ +""" +Update Oracle Prices - Update oracle prices on Reya DEX. + +This script demonstrates how to update oracle prices on Reya DEX +using signed price payloads. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_PRIVATE_KEY_1: Your Ethereum private key + +Usage: + python -m examples.rpc.update_oracle_prices +""" + from sdk.reya_rpc import get_config, update_oracle_prices def main(): - """ - Example script demonstrating how to update oracle prices on Reya DEX - using signed price payloads. - """ + """Execute oracle price update example.""" # Load configuration config = get_config() diff --git a/examples/rpc/withdraw_and_bridge_out.py b/examples/rpc/withdraw_and_bridge_out.py index 1cc76d43..06560d70 100644 --- a/examples/rpc/withdraw_and_bridge_out.py +++ b/examples/rpc/withdraw_and_bridge_out.py @@ -1,3 +1,18 @@ +""" +Withdraw and Bridge Out - Withdraw rUSD and bridge to Arbitrum. + +This script demonstrates how to withdraw rUSD from a margin account +and bridge it out from Reya Network to Arbitrum. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- PERP_ACCOUNT_ID_1: Your Reya margin account ID +- PERP_PRIVATE_KEY_1: Your Ethereum private key + +Usage: + python -m examples.rpc.withdraw_and_bridge_out +""" + import os from dotenv import load_dotenv @@ -7,16 +22,13 @@ def main(): - """ - Example script demonstrating how to withdraw rUSD from a margin account - and bridge it out from Reya Network to Arbitrum. - """ + """Execute withdraw and bridge out operation.""" # Load environment variables from .env file load_dotenv() # Retrieve the margin account ID from environment variables - account_id = int(os.environ["ACCOUNT_ID"]) + account_id = int(os.environ["PERP_ACCOUNT_ID_1"]) # Load configuration config = get_config() diff --git a/examples/websocket/__init__.py b/examples/websocket/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/websocket/perps/__init__.py b/examples/websocket/perps/__init__.py new file mode 100644 index 00000000..8feb19d2 --- /dev/null +++ b/examples/websocket/perps/__init__.py @@ -0,0 +1,10 @@ +""" +WebSocket examples for perpetual (perps) trading. + +These examples demonstrate how to subscribe to real-time data streams +for perpetual futures markets including: +- Market summary updates +- Price feeds +- Wallet positions and orders +- Trade executions +""" diff --git a/examples/websocket/market_monitoring.py b/examples/websocket/perps/market_monitoring.py similarity index 74% rename from examples/websocket/market_monitoring.py rename to examples/websocket/perps/market_monitoring.py index 120fa126..8b82c880 100644 --- a/examples/websocket/market_monitoring.py +++ b/examples/websocket/perps/market_monitoring.py @@ -1,14 +1,15 @@ -"""Basic example of using the resource-oriented WebSocket API client. +""" +Market Monitoring - Monitor market data streams via WebSocket. This example connects to the Reya WebSocket API and subscribes to market data streams. -Before running this example, ensure you have a .env file with the following variables: -- PRIVATE_KEY: Your Ethereum private key -- ACCOUNT_ID: Your Reya account ID +Requirements: - CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) -""" +- REYA_WS_URL: (optional) WebSocket URL, defaults based on chain ID -from typing import Any +Usage: + python -m examples.websocket.perps.market_monitoring +""" import asyncio import json @@ -18,9 +19,13 @@ from dotenv import load_dotenv +from sdk.async_api.error_message_payload import ErrorMessagePayload from sdk.async_api.market_perp_execution_update_payload import MarketPerpExecutionUpdatePayload from sdk.async_api.market_summary_update_payload import MarketSummaryUpdatePayload from sdk.async_api.markets_summary_update_payload import MarketsSummaryUpdatePayload +from sdk.async_api.ping_message_payload import PingMessagePayload +from sdk.async_api.pong_message_payload import PongMessagePayload +from sdk.async_api.subscribed_message_payload import SubscribedMessagePayload from sdk.reya_websocket import ReyaSocket from sdk.reya_websocket.config import WebSocketConfig @@ -45,11 +50,8 @@ def on_open(ws): ws.market.perp_executions("ETHRUSDPERP").subscribe() -def handle_markets_summary_data(message: dict[str, Any]) -> None: - """Handle /v2/markets/summary channel data with proper type conversion.""" - - payload = MarketsSummaryUpdatePayload.model_validate(message) - +def handle_markets_summary_data(payload: MarketsSummaryUpdatePayload) -> None: + """Handle /v2/markets/summary channel data.""" logger.info("📊 All Markets Summary Update:") logger.info(f" ├─ Timestamp: {payload.timestamp}") logger.info(f" ├─ Channel: {payload.channel}") @@ -67,11 +69,8 @@ def handle_markets_summary_data(message: dict[str, Any]) -> None: logger.info(f" ... and {len(payload.data) - 3} more markets") -def handle_market_summary_data(message: dict[str, Any]) -> None: - """Handle /v2/market/:symbol/summary channel data with proper type conversion.""" - - # Convert raw message to typed payload - payload = MarketSummaryUpdatePayload.model_validate(message) +def handle_market_summary_data(payload: MarketSummaryUpdatePayload) -> None: + """Handle /v2/market/:symbol/summary channel data.""" market = payload.data logger.info(f"📈 Market Summary Update for {market.symbol}:") @@ -88,11 +87,8 @@ def handle_market_summary_data(message: dict[str, Any]) -> None: logger.info(f" └─ Pool Price: {market.throttled_pool_price or 'N/A'}") -def handle_market_perp_executions_data(message: dict[str, Any]) -> None: - """Handle /v2/market/:symbol/perpExecutions channel data with proper type conversion.""" - - payload = MarketPerpExecutionUpdatePayload.model_validate(message) - +def handle_market_perp_executions_data(payload: MarketPerpExecutionUpdatePayload) -> None: + """Handle /v2/market/:symbol/perpExecutions channel data.""" logger.info("⚡ Market Perpetual Executions Update:") logger.info(f" ├─ Timestamp: {payload.timestamp}") logger.info(f" ├─ Channel: {payload.channel}") @@ -115,43 +111,44 @@ def handle_market_perp_executions_data(message: dict[str, Any]) -> None: def on_message(ws, message): - """Handle WebSocket messages with proper type conversion and dedicated handlers.""" - message_type = message.get("type") - - if message_type == "subscribed": - channel = message.get("channel", "unknown") - logger.info(f"✅ Successfully subscribed to {channel}") - - # Log the initial data from subscription - if "contents" in message: - logger.info(f"📦 Initial data received: {len(str(message['contents']))} characters") - - elif message_type == "channel_data": - channel = message.get("channel", "unknown") - - # Route to appropriate handler based on channel pattern - if channel == "/v2/markets/summary": - handle_markets_summary_data(message) - elif "/v2/market/" in channel and "/summary" in channel: - handle_market_summary_data(message) - elif "/v2/market/" in channel and "/perpExecutions" in channel: - handle_market_perp_executions_data(message) - else: - logger.warning(f"🔍 Unhandled channel data: {channel}") - - elif message_type == "ping": + """Handle WebSocket messages - receives typed Pydantic models from SDK.""" + # Handle subscription confirmations + if isinstance(message, SubscribedMessagePayload): + logger.info(f"✅ Successfully subscribed to {message.channel}") + if message.contents: + logger.info(f"📦 Initial data received: {len(str(message.contents))} characters") + return + + # Handle market data updates + if isinstance(message, MarketsSummaryUpdatePayload): + handle_markets_summary_data(message) + return + + if isinstance(message, MarketSummaryUpdatePayload): + handle_market_summary_data(message) + return + + if isinstance(message, MarketPerpExecutionUpdatePayload): + handle_market_perp_executions_data(message) + return + + # Handle ping/pong + if isinstance(message, PingMessagePayload): logger.info("🏓 Received ping from server, sending pong response") ws.send(json.dumps({"type": "pong"})) - logger.debug("✅ Pong sent successfully") + return - elif message_type == "pong": + if isinstance(message, PongMessagePayload): logger.info("🏓 Connection confirmed via pong response") + return - elif message_type == "error": - logger.error(f"❌ Error: {message.get('message', 'unknown error')}") + # Handle errors + if isinstance(message, ErrorMessagePayload): + logger.error(f"❌ Error: {message.message}") + return - else: - logger.debug(f"🔍 Received message type: {message_type}") + # Unknown message type + logger.debug(f"🔍 Received unhandled message type: {type(message).__name__}") async def periodic_task(ws): diff --git a/examples/websocket/prices_monitoring.py b/examples/websocket/perps/prices_monitoring.py similarity index 72% rename from examples/websocket/prices_monitoring.py rename to examples/websocket/perps/prices_monitoring.py index f343f2fd..5163dc81 100644 --- a/examples/websocket/prices_monitoring.py +++ b/examples/websocket/perps/prices_monitoring.py @@ -1,14 +1,15 @@ -"""Example of monitoring asset pair prices using the WebSocket API. +""" +Prices Monitoring - Monitor asset pair prices via WebSocket. This example connects to the Reya WebSocket API and subscribes to price data streams. -Before running this example, ensure you have a .env file with the following variables: -- PRIVATE_KEY: Your Ethereum private key -- ACCOUNT_ID: Your Reya account ID +Requirements: - CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) -""" +- REYA_WS_URL: (optional) WebSocket URL, defaults based on chain ID -from typing import Any +Usage: + python -m examples.websocket.perps.prices_monitoring +""" import asyncio import json @@ -18,8 +19,12 @@ from dotenv import load_dotenv +from sdk.async_api.error_message_payload import ErrorMessagePayload +from sdk.async_api.ping_message_payload import PingMessagePayload +from sdk.async_api.pong_message_payload import PongMessagePayload from sdk.async_api.price_update_payload import PriceUpdatePayload from sdk.async_api.prices_update_payload import PricesUpdatePayload +from sdk.async_api.subscribed_message_payload import SubscribedMessagePayload from sdk.reya_websocket import ReyaSocket from sdk.reya_websocket.config import WebSocketConfig @@ -44,11 +49,8 @@ def on_open(ws): ws.prices.price(SYMBOL).subscribe() -def handle_all_prices_data(message: dict[str, Any]) -> None: - """Handle /v2/prices channel data with proper type conversion.""" - - payload = PricesUpdatePayload.model_validate(message) - +def handle_all_prices_data(payload: PricesUpdatePayload) -> None: + """Handle /v2/prices channel data.""" logger.info("💰 All Prices Update:") logger.info(f" ├─ Timestamp: {payload.timestamp}") logger.info(f" ├─ Channel: {payload.channel}") @@ -65,11 +67,8 @@ def handle_all_prices_data(message: dict[str, Any]) -> None: logger.info(f" ... and {len(payload.data) - 5} more prices") -def handle_single_price_data(message: dict[str, Any]) -> None: - """Handle /v2/prices/:symbol channel data with proper type conversion.""" - - # Convert raw message to typed payload - payload = PriceUpdatePayload.model_validate(message) +def handle_single_price_data(payload: PriceUpdatePayload) -> None: + """Handle /v2/prices/:symbol channel data.""" price = payload.data logger.info(f"💵 Price Update for {price.symbol}:") @@ -81,41 +80,40 @@ def handle_single_price_data(message: dict[str, Any]) -> None: def on_message(ws, message): - """Handle WebSocket messages with proper type conversion and dedicated handlers.""" - message_type = message.get("type") - - if message_type == "subscribed": - channel = message.get("channel", "unknown") - logger.info(f"✅ Successfully subscribed to {channel}") - - # Log the initial data from subscription - if "contents" in message: - logger.info(f"📦 Initial data received: {len(str(message['contents']))} characters") - - elif message_type == "channel_data": - channel = message.get("channel", "unknown") - - # Route to appropriate handler based on channel pattern - if channel == "/v2/prices": - handle_all_prices_data(message) - elif "/v2/prices/" in channel: - handle_single_price_data(message) - else: - logger.warning(f"🔍 Unhandled channel data: {channel}") - - elif message_type == "ping": + """Handle WebSocket messages - receives typed Pydantic models from SDK.""" + # Handle subscription confirmations + if isinstance(message, SubscribedMessagePayload): + logger.info(f"✅ Successfully subscribed to {message.channel}") + if message.contents: + logger.info(f"📦 Initial data received: {len(str(message.contents))} characters") + return + + # Handle price data updates + if isinstance(message, PricesUpdatePayload): + handle_all_prices_data(message) + return + + if isinstance(message, PriceUpdatePayload): + handle_single_price_data(message) + return + + # Handle ping/pong + if isinstance(message, PingMessagePayload): logger.info("🏓 Received ping from server, sending pong response") ws.send(json.dumps({"type": "pong"})) - logger.debug("✅ Pong sent successfully") + return - elif message_type == "pong": + if isinstance(message, PongMessagePayload): logger.info("🏓 Connection confirmed via pong response") + return - elif message_type == "error": - logger.error(f"❌ Error: {message.get('message', 'unknown error')}") + # Handle errors + if isinstance(message, ErrorMessagePayload): + logger.error(f"❌ Error: {message.message}") + return - else: - logger.debug(f"🔍 Received message type: {message_type}") + # Unknown message type + logger.debug(f"🔍 Received unhandled message type: {type(message).__name__}") async def periodic_task(ws): diff --git a/examples/websocket/wallet_monitoring.py b/examples/websocket/perps/wallet_monitoring.py similarity index 72% rename from examples/websocket/wallet_monitoring.py rename to examples/websocket/perps/wallet_monitoring.py index 5c4384cb..dcb5e570 100644 --- a/examples/websocket/wallet_monitoring.py +++ b/examples/websocket/perps/wallet_monitoring.py @@ -1,15 +1,16 @@ -"""Example of monitoring wallet positions, orders, and balances. +""" +Wallet Monitoring - Monitor wallet positions, orders, and balances via WebSocket. This example connects to the Reya WebSocket API and subscribes to wallet data streams for a specific address. -Before running this example, ensure you have a .env file with the following variables: -- PRIVATE_KEY: Your Ethereum private key -- ACCOUNT_ID: Your Reya account ID +Requirements: - CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) -- OWNER_WALLET_ADDRESS: The owner wallet address to monitor (required) -""" +- PERP_WALLET_ADDRESS_1: The wallet address to monitor +- REYA_WS_URL: (optional) WebSocket URL, defaults based on chain ID -from typing import Any +Usage: + python -m examples.websocket.perps.wallet_monitoring +""" import asyncio import json @@ -19,8 +20,12 @@ from dotenv import load_dotenv +from sdk.async_api.error_message_payload import ErrorMessagePayload from sdk.async_api.order_change_update_payload import OrderChangeUpdatePayload +from sdk.async_api.ping_message_payload import PingMessagePayload +from sdk.async_api.pong_message_payload import PongMessagePayload from sdk.async_api.position_update_payload import PositionUpdatePayload +from sdk.async_api.subscribed_message_payload import SubscribedMessagePayload from sdk.async_api.wallet_perp_execution_update_payload import ( WalletPerpExecutionUpdatePayload, ) @@ -38,10 +43,10 @@ def on_open(ws): """Handle WebSocket connection open event.""" logger.info("Connection established, subscribing to wallet data") - # Get owner wallet address from environment - wallet_address = os.environ.get("OWNER_WALLET_ADDRESS") + # Get wallet address from environment + wallet_address = os.environ.get("PERP_WALLET_ADDRESS_1") if not wallet_address: - logger.error("OWNER_WALLET_ADDRESS environment variable is required") + logger.error("PERP_WALLET_ADDRESS_1 environment variable is required") return logger.info(f"Monitoring wallet: {wallet_address}") @@ -56,12 +61,8 @@ def on_open(ws): # ws.wallet.order_changes(wallet_address).subscribe() -def handle_wallet_positions_data(message: dict[str, Any]) -> None: - """Handle /v2/wallet/:address/positions channel data with proper type conversion.""" - logger.info("NEW POSITION DATA") - - payload = PositionUpdatePayload.model_validate(message) - +def handle_wallet_positions_data(payload: PositionUpdatePayload) -> None: + """Handle /v2/wallet/:address/positions channel data.""" logger.info("💼 Wallet Positions Update:") logger.info(f" ├─ Timestamp: {payload.timestamp}") logger.info(f" ├─ Channel: {payload.channel}") @@ -82,12 +83,8 @@ def handle_wallet_positions_data(message: dict[str, Any]) -> None: logger.info(f" ... and {len(payload.data) - 5} more positions") -def handle_wallet_orders_data(message: dict[str, Any]) -> None: - """Handle /v2/wallet/:address/orderChanges channel data with proper type conversion.""" - - # Convert raw message to typed payload - payload = OrderChangeUpdatePayload.model_validate(message) - +def handle_wallet_orders_data(payload: OrderChangeUpdatePayload) -> None: + """Handle /v2/wallet/:address/orderChanges channel data.""" logger.info("📋 Wallet Open Orders Update:") logger.info(f" ├─ Timestamp: {payload.timestamp}") logger.info(f" ├─ Channel: {payload.channel}") @@ -107,11 +104,8 @@ def handle_wallet_orders_data(message: dict[str, Any]) -> None: logger.info(f" ... and {len(payload.data) - 5} more orders") -def handle_wallet_executions_data(message: dict[str, Any]) -> None: - """Handle /v2/wallet/:address/perpExecutions channel data with proper type conversion.""" - - payload = WalletPerpExecutionUpdatePayload.model_validate(message) - +def handle_wallet_executions_data(payload: WalletPerpExecutionUpdatePayload) -> None: + """Handle /v2/wallet/:address/perpExecutions channel data.""" logger.info("⚡ Wallet Perpetual Executions Update:") logger.info(f" ├─ Timestamp: {payload.timestamp}") logger.info(f" ├─ Channel: {payload.channel}") @@ -132,46 +126,44 @@ def handle_wallet_executions_data(message: dict[str, Any]) -> None: def on_message(ws, message): - """Handle WebSocket messages with proper type conversion and dedicated handlers.""" - message_type = message.get("type") - - if message_type == "subscribed": - channel = message.get("channel", "unknown") - logger.info(f"✅ Successfully subscribed to {channel}") - - # Log the initial data from subscription - if "contents" in message: - logger.info(f"📦 Initial data received: {len(str(message['contents']))} characters") - - elif message_type == "channel_data": - channel = message.get("channel", "unknown") - - # Route to appropriate handler based on channel pattern - if "/v2/wallet/" in channel: - if channel.endswith("/positions"): - handle_wallet_positions_data(message) - elif channel.endswith("/orderChanges"): - handle_wallet_orders_data(message) - elif channel.endswith("/perpExecutions"): - handle_wallet_executions_data(message) - else: - logger.warning(f"🔍 Unhandled wallet channel: {channel}") - else: - logger.warning(f"🔍 Unhandled channel data: {channel}") - - elif message_type == "ping": + """Handle WebSocket messages - receives typed Pydantic models from SDK.""" + # Handle subscription confirmations + if isinstance(message, SubscribedMessagePayload): + logger.info(f"✅ Successfully subscribed to {message.channel}") + if message.contents: + logger.info(f"📦 Initial data received: {len(str(message.contents))} characters") + return + + # Handle wallet data updates + if isinstance(message, PositionUpdatePayload): + handle_wallet_positions_data(message) + return + + if isinstance(message, OrderChangeUpdatePayload): + handle_wallet_orders_data(message) + return + + if isinstance(message, WalletPerpExecutionUpdatePayload): + handle_wallet_executions_data(message) + return + + # Handle ping/pong + if isinstance(message, PingMessagePayload): logger.info("🏓 Received ping from server, sending pong response") ws.send(json.dumps({"type": "pong"})) - logger.debug("✅ Pong sent successfully") + return - elif message_type == "pong": + if isinstance(message, PongMessagePayload): logger.info("🏓 Connection confirmed via pong response") + return - elif message_type == "error": - logger.error(f"❌ Error: {message.get('message', 'unknown error')}") + # Handle errors + if isinstance(message, ErrorMessagePayload): + logger.error(f"❌ Error: {message.message}") + return - else: - logger.debug(f"🔍 Received message type: {message_type}") + # Unknown message type + logger.debug(f"🔍 Received unhandled message type: {type(message).__name__}") async def periodic_task(ws, wallet_address): @@ -203,10 +195,10 @@ async def main(): load_dotenv() # Check if wallet address is set - wallet_address = os.environ.get("WALLET_ADDRESS") + wallet_address = os.environ.get("PERP_WALLET_ADDRESS_1") if not wallet_address: - logger.error("Please set the WALLET_ADDRESS environment variable") - logger.error("Add WALLET_ADDRESS=0x... to your .env file") + logger.error("Please set the PERP_WALLET_ADDRESS_1 environment variable") + logger.error("Add PERP_WALLET_ADDRESS_1=0x... to your .env file") return # Get WebSocket URL from environment diff --git a/examples/websocket/spot/__init__.py b/examples/websocket/spot/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/examples/websocket/spot/depth_market_maker.py b/examples/websocket/spot/depth_market_maker.py new file mode 100644 index 00000000..e0b0d5bb --- /dev/null +++ b/examples/websocket/spot/depth_market_maker.py @@ -0,0 +1,1003 @@ +#!/usr/bin/env python3 +""" +Spot Market Maker (WebSocket Version) - Maintains realistic depth around current ETH price. + +This version uses WebSocket for real-time updates instead of REST polling: +- Price updates via /v2/prices/{symbol} +- Balance updates via /v2/wallet/{address}/accountBalances +- Order changes via /v2/wallet/{address}/openOrders +- Spot executions via /v2/wallet/{address}/spotExecutions + +On startup, uses REST to load initial state, then switches to WebSocket for updates. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- SPOT_ACCOUNT_ID_1: Your Reya SPOT account ID +- SPOT_PRIVATE_KEY_1: Your Ethereum private key +- SPOT_WALLET_ADDRESS_1: Your wallet address + +Usage: + python -m examples.websocket.spot.depth_market_maker + +Press Ctrl+C to stop (will cancel all orders on exit). +""" + +from typing import Optional + +import argparse +import asyncio +import logging +import os +import random +import threading +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_spot_execution_update_payload import WalletSpotExecutionUpdatePayload +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 + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger("market_maker_ws") + +# Enable verbose HTTP logging for debugging API issues +logging.getLogger("aiohttp.client").setLevel(logging.DEBUG) +logging.getLogger("urllib3").setLevel(logging.DEBUG) + +# 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_MAX_SPREAD_PCT = Decimal("0.01") # ±1% from reference price (configurable via --max-spread) +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 + + +@dataclass +class OpenOrder: + """Represents an open order with its key attributes.""" + + order_id: str + price: Decimal + qty: Decimal + is_buy: bool + + +@dataclass +class MarketParams: + """Market parameters fetched from /spotMarketDefinitions endpoint.""" + + symbol: str + base_asset: str + quote_asset: str + tick_size: Decimal + min_order_qty: Decimal + qty_step_size: Decimal + + +@dataclass +class MarketMakerState: + """Thread-safe state container for the market maker.""" + + # Symbol configuration (set once on startup) + symbol: str = DEFAULT_SYMBOL + oracle_symbol: str = DEFAULT_ORACLE_SYMBOL + 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 + + # Dynamic state (updated via WebSocket) + reference_price: Decimal = Decimal("0") + base_balance: Decimal = Decimal("0") # ETH + quote_balance: Decimal = Decimal("0") # RUSD + open_orders: dict[str, OpenOrder] = field(default_factory=dict) + + # Lock for thread-safe access + _lock: threading.Lock = field(default_factory=threading.Lock) + + def update_price(self, price: Decimal) -> None: + """Update reference price (thread-safe).""" + with self._lock: + old_price = self.reference_price + self.reference_price = price + if old_price != price: + logger.debug(f"📊 Price updated: ${old_price} → ${price}") + + def update_balance(self, asset: str, balance: Decimal) -> None: + """Update balance for an asset (thread-safe).""" + with self._lock: + if self.market_params and asset == self.market_params.base_asset: + old = self.base_balance + self.base_balance = balance + if old != balance: + logger.info(f"💰 {asset} balance: {old} → {balance}") + elif self.market_params and asset == self.market_params.quote_asset: + old = self.quote_balance + self.quote_balance = balance + if old != balance: + logger.info(f"💰 {asset} balance: {old} → {balance}") + + def update_order( + self, order_id: str, status: str, price: Decimal, qty: Decimal, cum_qty: Decimal, is_buy: bool + ) -> None: + """Update or remove an order based on status (thread-safe).""" + 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, maker_account_id: int) -> None: + """Log a spot execution.""" + side_str = "BOUGHT" if side == "B" else "SOLD" + logger.info(f"🔔 FILL: {side_str} {qty} @ ${price} (order {order_id}, counterparty: {maker_account_id})") + + def remove_order(self, order_id: str) -> None: + """Remove an order from local state (thread-safe). Used when cancel fails with 'Order not found'.""" + with self._lock: + if order_id in self.open_orders: + del self.open_orders[order_id] + logger.debug(f"📋 Order {order_id} removed from local state (stale)") + + def sync_orders(self, fresh_orders: dict[str, OpenOrder]) -> None: + """Replace local orders with fresh data from REST API (thread-safe).""" + with self._lock: + old_count = len(self.open_orders) + self.open_orders = fresh_orders + new_count = len(self.open_orders) + if old_count != new_count: + logger.info(f"🔄 State synced: {old_count} → {new_count} orders") + + def get_snapshot(self) -> tuple[Decimal, Decimal, Decimal, list[OpenOrder], list[OpenOrder]]: + """Get a consistent snapshot of current state (thread-safe).""" + with self._lock: + bids = [o for o in self.open_orders.values() if o.is_buy] + asks = [o for o in self.open_orders.values() if not o.is_buy] + bids.sort(key=lambda o: o.price, reverse=True) + asks.sort(key=lambda o: o.price) + return ( + self.reference_price, + self.base_balance, + self.quote_balance, + bids, + asks, + ) + + +def round_to_tick(price: Decimal, tick_size: Decimal) -> Decimal: + """Round price down to nearest tick size.""" + return (price / tick_size).quantize(Decimal("1"), rounding=ROUND_DOWN) * tick_size + + +def round_to_qty_step(qty: Decimal, qty_step_size: Decimal) -> Decimal: + """Round quantity down to nearest qty step size.""" + return (qty / qty_step_size).quantize(Decimal("1"), rounding=ROUND_DOWN) * qty_step_size + + +def generate_random_qty(min_qty: Decimal, max_qty: Decimal, qty_step_size: Decimal) -> str: + """Generate a random quantity between min and max, rounded to qty step size.""" + 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) + + qty = max(qty, min_qty) + + return str(qty) + + +def calculate_available_balance( + base_balance: Decimal, + quote_balance: Decimal, + bids: list[OpenOrder], + asks: list[OpenOrder], +) -> tuple[Decimal, Decimal]: + """Calculate available balance after subtracting committed amounts.""" + 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), + max(Decimal("0"), quote_balance - committed_quote), + ) + + +def generate_quote_prices( + reference: Decimal, + max_deviation_pct: Decimal, + num_levels: int, + tick_size: Decimal, +) -> tuple[list[str], list[str]]: + """Generate bid and ask prices around the reference price.""" + min_price = reference * (1 - max_deviation_pct) + max_price = reference * (1 + max_deviation_pct) + + bid_range = reference - min_price + bids = [] + 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 = [] + 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: + """Generate a single random price that doesn't cross the spread.""" + 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: + if is_buy: + return round_to_tick(min_price, tick_size) + else: + return round_to_tick(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) + + +class WebSocketHandler: + """Handles WebSocket events and updates market maker state.""" + + def __init__(self, state: MarketMakerState): + self.state = state + self._connected = threading.Event() + + def wait_for_connection(self, timeout: float = 10.0) -> bool: + """Wait for WebSocket to connect and subscribe.""" + return self._connected.wait(timeout) + + def on_open(self, ws: ReyaSocket) -> None: + """Handle WebSocket connection open - subscribe to channels.""" + logger.info("🔌 WebSocket connected, subscribing to channels...") + + wallet = self.state.wallet_address + if not wallet: + logger.error("No wallet address set in state") + return + + # Subscribe to price updates for oracle symbol + ws.prices.price(self.state.oracle_symbol).subscribe() + + # Subscribe to wallet channels + ws.wallet.balances(wallet).subscribe() + ws.wallet.order_changes(wallet).subscribe() + ws.wallet.spot_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}/spotExecutions") + + def on_message(self, _ws: ReyaSocket, message: WebSocketMessage) -> None: + """Handle incoming WebSocket messages.""" + + # Handle subscription confirmations + if isinstance(message, SubscribedMessagePayload): + logger.debug(f"Subscribed to {message.channel}") + # Mark as connected after we get subscription confirmations + self._connected.set() + return + + # Handle price updates + 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 + + # Handle balance updates + if isinstance(message, AccountBalanceUpdatePayload): + for balance in message.data: + if balance.account_id == self.state.account_id: + self.state.update_balance(balance.asset, Decimal(balance.real_balance)) + return + + # Handle order changes + 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 + + # Handle spot executions + if isinstance(message, WalletSpotExecutionUpdatePayload): + for execution in message.data: + if execution.symbol != self.state.symbol: + continue + if execution.order_id is None: + continue + self.state.log_execution( + order_id=execution.order_id, + qty=execution.qty, + price=execution.price, + side=execution.side.value, + maker_account_id=execution.maker_account_id, + ) + return + + def on_error(self, _ws: ReyaSocket, error: Exception) -> None: + """Handle WebSocket errors.""" + logger.error(f"WebSocket error: {error}") + + def on_close(self, _ws: ReyaSocket, close_status_code: int, close_msg: str) -> None: + """Handle WebSocket close.""" + logger.warning(f"WebSocket closed: {close_status_code} - {close_msg}") + self._connected.clear() + + +async def fetch_market_definition(client: ReyaTradingClient, symbol: str) -> MarketParams: + """Fetch market definition from /spotMarketDefinitions endpoint.""" + spot_definitions = await client.reference.get_spot_market_definitions() + + for market in spot_definitions: + if market.symbol == symbol: + return MarketParams( + symbol=market.symbol, + base_asset=market.base_asset, + quote_asset=market.quote_asset, + tick_size=Decimal(market.tick_size), + min_order_qty=Decimal(market.min_order_qty), + qty_step_size=Decimal(market.qty_step_size), + ) + + raise RuntimeError(f"Market definition not found for symbol: {symbol}") + + +async def fetch_initial_state( + client: ReyaTradingClient, + state: MarketMakerState, +) -> None: + """Fetch initial state via REST before WebSocket takes over.""" + 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") + + # Fetch oracle price + 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) + + # Fetch account balances + logger.info(" Fetching account balances...") + balances = await client.get_account_balances() + for balance in balances: + if balance.account_id == account_id: + if balance.asset == market_params.base_asset: + state.base_balance = Decimal(balance.real_balance) + elif balance.asset == market_params.quote_asset: + state.quote_balance = Decimal(balance.real_balance) + + # Fetch open orders + 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: + """Refresh order state from REST API to handle WebSocket disconnections or stale state.""" + try: + open_orders = await client.get_open_orders() + fresh_orders: 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_orders[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_orders) + except (OSError, RuntimeError) as e: + logger.warning(f"Failed to refresh state from REST: {e}") + + +async def place_single_order( + client: ReyaTradingClient, + symbol: str, + price: str, + is_buy: bool, + market_params: MarketParams, + available_balance: Decimal, + max_retries: int = 3, +) -> tuple[bool, Decimal]: + """ + 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" + + # Calculate max affordable quantity based on local tracking + if is_buy: + max_affordable_qty = available_balance / price_decimal if price_decimal > 0 else Decimal("0") + else: + max_affordable_qty = available_balance + + max_qty = min(MAX_ORDER_QTY, max_affordable_qty) + + # 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 + qty = str(market_params.min_order_qty) + logger.debug(f" Local balance low, trying {side} @ ${price} with min qty={qty}") + + for attempt in range(max_retries): + try: + await client.create_limit_order( + LimitOrderParameters( + symbol=symbol, + is_buy=is_buy, + limit_px=price, + qty=qty, + time_in_force=TimeInForce.GTC, + ) + ) + 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) 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 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 + # 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") + + return False, Decimal("0") + + +async def place_orders( + client: ReyaTradingClient, + symbol: str, + bids: list[str], + asks: list[str], + market_params: MarketParams, + available_base: Decimal, + available_quote: Decimal, +) -> int: + """Place bid and ask orders with random quantities, respecting available balance.""" + order_count = 0 + remaining_quote = available_quote + remaining_base = available_base + + for price in bids: + success, qty_used = await place_single_order( + client, + symbol, + price, + is_buy=True, + market_params=market_params, + available_balance=remaining_quote, + ) + if success: + order_count += 1 + remaining_quote -= qty_used + + for price in asks: + success, qty_used = await place_single_order( + client, + symbol, + price, + is_buy=False, + market_params=market_params, + available_balance=remaining_base, + ) + if success: + order_count += 1 + remaining_base -= qty_used + + return order_count + + +def find_out_of_range_orders( + bids: list[OpenOrder], + asks: list[OpenOrder], + reference_price: Decimal, + max_spread_pct: Decimal, +) -> list[OpenOrder]: + """Find orders that are outside the allowed price range.""" + min_price = reference_price * (1 - max_spread_pct) + max_price = reference_price * (1 + max_spread_pct) + + out_of_range = [] + for order in bids + asks: + if order.price < min_price or order.price > max_price: + out_of_range.append(order) + + return out_of_range + + +async def cancel_and_replace_order( + client: ReyaTradingClient, + symbol: str, + account_id: int, + order: OpenOrder, + reference_price: Decimal, + market_params: MarketParams, + available_base: Decimal, + available_quote: Decimal, + remaining_bids: list[OpenOrder], + remaining_asks: list[OpenOrder], + 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. + + If order placement fails due to insufficient balance, retries with minimum quantity. + """ + 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, + ) + + # Calculate available balance after cancelling this order + if order.is_buy: + freed_quote = order.price * order.qty + total_available_quote = available_quote + freed_quote + max_affordable_qty = total_available_quote / new_price if new_price > 0 else Decimal("0") + max_qty = min(MAX_ORDER_QTY, max_affordable_qty) + else: + freed_base = order.qty + total_available_base = available_base + freed_base + max_qty = min(MAX_ORDER_QTY, total_available_base) + + if max_qty < market_params.min_order_qty: + logger.warning(f"[{cycle:04d}] Skipping {side} replacement - insufficient balance") + try: + 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) 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) + logger.info(f"[{cycle:04d}] Removed stale {side} @ ${order.price} from local state") + else: + logger.warning(f"[{cycle:04d}] Failed to cancel {side} @ ${order.price}: {e}") + return False + + new_qty = generate_random_qty(market_params.min_order_qty, max_qty, market_params.qty_step_size) + + # Cancel the existing order first + try: + 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) 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) + logger.info(f"[{cycle:04d}] Removed stale {side} @ ${order.price} from local state") + else: + logger.warning(f"[{cycle:04d}] Failed to cancel {side} @ ${order.price}: {e}") + return False + + await asyncio.sleep(0.1) + + # Try to place the new order, retrying with min qty if balance issues + qty_to_use = new_qty + for attempt in range(max_retries): + try: + 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, + ) + ) + return True + except (OSError, RuntimeError) 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 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 adjust_orders( + client: ReyaTradingClient, + state: MarketMakerState, + cycle: int, +) -> None: + """Adjust orders based on current state from WebSocket updates.""" + market_params = state.market_params + account_id = state.account_id + + if not market_params or not account_id: + return + + # Get consistent snapshot of current state + reference_price, base_balance, quote_balance, bids, asks = state.get_snapshot() + + if reference_price == Decimal("0"): + logger.warning(f"[{cycle:04d}] No reference price available, skipping adjustment") + return + + # Calculate available balance + available_base, available_quote = calculate_available_balance(base_balance, quote_balance, bids, asks) + + min_price = reference_price * (1 - state.max_spread_pct) + max_price = reference_price * (1 + state.max_spread_pct) + + # Check for out-of-range orders first + 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 price: ${reference_price} | Range: ${min_price:.2f} - ${max_price:.2f}") + logger.info(f"[{cycle:04d}] ⚠️ Found {len(out_of_range)} order(s) outside range, adjusting...") + + 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_base=available_base, + available_quote=available_quote, + 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 + + # Normal operation: pick one random order to adjust + # First pick a side (50-50), then pick a random order from that side + # This ensures balanced adjustment regardless of current order counts + if not bids and not asks: + logger.warning(f"[{cycle:04d}] No open orders to adjust") + return + + # Determine which side to adjust (50-50 if both sides have orders) + if bids and asks: + adjust_bid_side = random.choice([True, False]) # nosec B311 + elif bids: + adjust_bid_side = True + else: + adjust_bid_side = False + + if adjust_bid_side: + order_to_cancel = random.choice(bids) # nosec B311 + else: + order_to_cancel = 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_base=available_base, + available_quote=available_quote, + remaining_bids=remaining_bids, + remaining_asks=remaining_asks, + cycle=cycle, + state=state, + ) + + +async def main(symbol: str, oracle_symbol: str, max_spread_pct: Decimal): + load_dotenv() + + logger.info("=" * 60) + logger.info(f"🚀 SPOT Market Maker (WebSocket) for {symbol}") + logger.info("=" * 60) + + # Initialize state with symbol configuration + state = MarketMakerState(symbol=symbol, oracle_symbol=oracle_symbol, max_spread_pct=max_spread_pct) + + # Create config for SPOT account (uses SPOT_* env vars instead of PERP_*) + spot_config = TradingConfig.from_env_spot(account_number=1) + + async with ReyaTradingClient(config=spot_config) as client: + + await client.start() + + account_id = spot_config.account_id + wallet_address = spot_config.owner_wallet_address + + if not account_id: + raise ValueError("SPOT_ACCOUNT_ID_1 environment variable is required") + if not wallet_address: + raise ValueError("SPOT_WALLET_ADDRESS_1 environment variable is required") + + # Set up state with account info + state.account_id = account_id + state.wallet_address = wallet_address + + # Fetch market definition (REST - one time) + logger.info(f" Fetching market definition for {symbol}...") + state.market_params = await fetch_market_definition(client, symbol) + market_params = state.market_params + + # Fetch initial state via REST + 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" {market_params.base_asset} Balance: {state.base_balance}") + logger.info(f" {market_params.quote_asset} Balance: {state.quote_balance}") + 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) + + # Set up WebSocket + 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, + ) + + # Connect WebSocket in background thread + logger.info("🔌 Connecting WebSocket...") + websocket.connect() + + # Wait for WebSocket to connect and subscribe + 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 + 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: + # Initial setup: place all orders + logger.info("Placing initial liquidity...") + available_base, available_quote = calculate_available_balance( + state.base_balance, state.quote_balance, [], [] + ) + bid_prices, ask_prices = generate_quote_prices( + state.reference_price, state.max_spread_pct, NUM_LEVELS, market_params.tick_size + ) + order_count = await place_orders( + client, symbol, bid_prices, ask_prices, market_params, available_base, available_quote + ) + + bid_str = ", ".join(f"${b}" for b in bid_prices) + ask_str = ", ".join(f"${a}" for a in ask_prices) + logger.info(f"✅ Initial setup complete: {order_count} orders") + logger.info(f" Bids: {bid_str}") + logger.info(f" Asks: {ask_str}\n") + + # Main loop: adjust orders based on WebSocket state + cycle = 0 + while True: + await asyncio.sleep(REFRESH_INTERVAL) + cycle += 1 + + # Check for low balance - stop MM if ETH balance is too low + if state.base_balance < MIN_BASE_BALANCE: + logger.warning( + f"[{cycle:04d}] ⚠️ ETH balance ({state.base_balance}) below minimum ({MIN_BASE_BALANCE})" + ) + logger.warning(f"[{cycle:04d}] 🛑 Stopping market maker due to low balance...") + break + + # Periodically refresh state from REST to handle WS disconnections + if cycle % STATE_REFRESH_CYCLES == 0: + logger.info(f"[{cycle:04d}] 🔄 Refreshing state from REST API...") + await refresh_state_from_rest(client, state) + + # State is automatically updated via WebSocket + # Just run the adjustment logic + await adjust_orders(client, state, cycle) + + except (KeyboardInterrupt, asyncio.CancelledError): + pass + finally: + logger.info("\n🛑 Shutting down...") + + # Close WebSocket + logger.info("Closing WebSocket...") + websocket.close() + + logger.info("Cancelling all orders...") + try: + await client.mass_cancel(symbol=symbol, account_id=account_id) + logger.info("✅ Market maker stopped") + except (OSError, RuntimeError) as e: + logger.warning(f"Cleanup failed: {e}") + + +def parse_args(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser(description="Spot Market Maker - Maintains realistic depth around current price") + parser.add_argument( + "--symbol", type=str, default=DEFAULT_SYMBOL, help=f"Spot trading pair symbol (default: {DEFAULT_SYMBOL})" + ) + parser.add_argument( + "--oracle-symbol", + type=str, + default=DEFAULT_ORACLE_SYMBOL, + help=f"Oracle price symbol for reference pricing (default: {DEFAULT_ORACLE_SYMBOL})", + ) + parser.add_argument( + "--max-spread", + type=float, + default=float(DEFAULT_MAX_SPREAD_PCT), + help=f"Maximum bid-ask spread as decimal (e.g., 0.01 for 1%%, 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/spot/spot_executions.py b/examples/websocket/spot/spot_executions.py new file mode 100644 index 00000000..26e2c7ad --- /dev/null +++ b/examples/websocket/spot/spot_executions.py @@ -0,0 +1,190 @@ +""" +Spot Executions Monitoring - Listen to spot execution updates via WebSocket. + +This example connects to the Reya WebSocket API and subscribes to spot execution +updates for a specific wallet address. + +Requirements: +- CHAIN_ID: The chain ID (1729 for mainnet, 89346162 for testnet) +- SPOT_WALLET_ADDRESS_1: The wallet address to monitor for spot executions + +Usage: + python -m examples.websocket.spot.spot_executions +""" + +import asyncio +import json +import logging +import os +import time + +from dotenv import load_dotenv + +from sdk.async_api.error_message_payload import ErrorMessagePayload +from sdk.async_api.ping_message_payload import PingMessagePayload +from sdk.async_api.pong_message_payload import PongMessagePayload +from sdk.async_api.subscribed_message_payload import SubscribedMessagePayload +from sdk.async_api.wallet_spot_execution_update_payload import WalletSpotExecutionUpdatePayload +from sdk.reya_websocket import ReyaSocket, WebSocketMessage +from sdk.reya_websocket.config import WebSocketConfig + +# ============================================================================= +# LOGGING SETUP +# ============================================================================= + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger("reya.spot_executions") + + +# ============================================================================= +# WEBSOCKET HANDLERS +# ============================================================================= + + +def on_open(ws): + """Handle WebSocket connection open event.""" + logger.info("Connection established, subscribing to spot executions") + + wallet_address = os.environ.get("SPOT_WALLET_ADDRESS_1") + if not wallet_address: + logger.error("SPOT_WALLET_ADDRESS_1 environment variable is required") + return + + logger.info(f"Monitoring wallet: {wallet_address}") + + # Subscribe to wallet spot executions + ws.wallet.spot_executions(wallet_address).subscribe() + + +def handle_wallet_spot_executions_data(payload: WalletSpotExecutionUpdatePayload) -> None: + """Handle /v2/wallet/:address/spotExecutions channel data.""" + logger.info("⚡ Wallet Spot Executions Update:") + logger.info(f" ├─ Timestamp: {payload.timestamp}") + logger.info(f" ├─ Channel: {payload.channel}") + logger.info(f" └─ Executions Count: {len(payload.data)}") + + for i, execution in enumerate(payload.data[:5]): + logger.info(f" Execution {i + 1}: {execution.symbol}") + logger.info(f" ├─ Account ID: {execution.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" ├─ Type: {execution.type.value}") + logger.info(f" └─ Order ID: {execution.order_id}") + + if len(payload.data) > 5: + logger.info(f" ... and {len(payload.data) - 5} more executions") + + +def on_message(ws, message: WebSocketMessage): + """Handle WebSocket messages - receives typed Pydantic models from the SDK.""" + if isinstance(message, SubscribedMessagePayload): + logger.info(f"✅ Successfully subscribed to {message.channel}") + if message.contents is not None: + logger.info(f"📦 Initial data received: {len(str(message.contents))} characters") + + elif isinstance(message, WalletSpotExecutionUpdatePayload): + handle_wallet_spot_executions_data(message) + + elif isinstance(message, PingMessagePayload): + logger.info("🏓 Received ping from server, sending pong response") + ws.send(json.dumps({"type": "pong"})) + logger.debug("✅ Pong sent successfully") + + elif isinstance(message, PongMessagePayload): + logger.info("🏓 Connection confirmed via pong response") + + elif isinstance(message, ErrorMessagePayload): + logger.error(f"❌ Error: {message.message}") + + else: + logger.debug(f"🔍 Received message type: {type(message).__name__}") + + +# ============================================================================= +# PERIODIC TASK +# ============================================================================= + + +async def periodic_task(ws, wallet_address: str): + """Periodic task with connection monitoring.""" + counter = 0 + start_time = time.time() + + while True: + counter += 1 + uptime = time.time() - start_time + + logger.info(f"🔄 Monitoring wallet {wallet_address[:8]}... (iteration {counter}) - Uptime: {uptime:.1f}s") + + active_subs = len(ws.active_subscriptions) + logger.info(f"📊 Connection Status: {active_subs} active subscriptions") + + if counter % 10 == 0: + logger.info("🏓 Sending manual ping to test connection") + ws.send(json.dumps({"type": "ping"})) + + await asyncio.sleep(2) + + +# ============================================================================= +# MAIN +# ============================================================================= + + +async def main(): + """Main entry point for the example.""" + load_dotenv() + + wallet_address = os.environ.get("SPOT_WALLET_ADDRESS_1") + if not wallet_address: + logger.error("Please set the SPOT_WALLET_ADDRESS_1 environment variable") + logger.error("Add SPOT_WALLET_ADDRESS_1=0x... to your .env file") + return + + ws_url = os.environ.get("REYA_WS_URL", "wss://ws.reya.xyz/") + + def on_error(_ws, error): + """Handle WebSocket errors.""" + logger.error(f"❌ WebSocket error: {error}") + + def on_close(_ws, close_status_code, close_reason): + """Handle WebSocket close events.""" + logger.info(f"🔌 WebSocket closed: {close_status_code} - {close_reason}") + if close_status_code != 1000: + logger.warning(f"⚠️ Abnormal closure detected. Status: {close_status_code}") + + config = WebSocketConfig( + url=ws_url, + ping_interval=20, + ping_timeout=15, + connection_timeout=60, + reconnect_attempts=5, + reconnect_delay=3, + ) + + ws = ReyaSocket( + config=config, + on_open=on_open, + on_message=on_message, + on_error=on_error, + on_close=on_close, + ) + + logger.info(f"Connecting to WebSocket to monitor spot executions for wallet: {wallet_address}") + logger.info("Press Ctrl+C to exit") + + ws.connect() + + asyncio.create_task(periodic_task(ws, wallet_address)) + + while True: + await asyncio.sleep(1) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("\nProgram interrupted by user. Exiting...") diff --git a/pyproject.toml b/pyproject.toml index 737df8f0..c650d107 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "reya-python-sdk" -version = "2.0.7.1" +version = "2.1.3.0" description = "SDK for interacting with Reya Labs APIs" authors = [ {name = "Reya Labs"} @@ -124,6 +124,7 @@ ignore-paths = ["sdk/async_api"] disable = """ consider-using-in, duplicate-code, + f-string-without-interpolation, fixme, import-error, inconsistent-return-statements, @@ -142,9 +143,12 @@ disable = """ too-few-public-methods, too-many-arguments, too-many-branches, + too-many-locals, + too-many-nested-blocks, too-many-positional-arguments, too-many-public-methods, too-many-return-statements, + too-many-statements, too-many-instance-attributes """ @@ -153,3 +157,28 @@ disable = """ # Directories that are not visited by pytest collector: norecursedirs =["hooks", "*.egg", ".eggs", "dist", "build", "docs", ".tox", ".git", "__pycache__"] doctest_optionflags = ["NUMBER", "NORMALIZE_WHITESPACE", "IGNORE_EXCEPTION_DETAIL"] +asyncio_mode = "auto" +# Use session-scoped event loop for all async fixtures and tests (pytest-asyncio 0.24+) +# This enables sharing a single WebSocket/API connection across all tests +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" + +# Test markers for categorization +markers = [ + "spot: Spot trading tests (ETHRUSD, BTCRUSD)", + "perp: Perpetual trading tests (ETHRUSDPERP, BTCRUSDPERP)", + "market_data: Market data endpoint tests", + "slow: Tests that take longer than 5 seconds", + "maker_taker: Tests requiring two accounts (maker and taker)", + "websocket: Tests that verify WebSocket events", + "rest: Tests that verify REST API responses", + "e2e: Full end-to-end flow tests", + "ioc: Immediate-Or-Cancel order tests", + "gtc: Good-Till-Cancelled order tests", + "trigger: Trigger order tests (SL/TP)", + "cancel: Order cancellation tests", + "balance: Balance verification tests", + "validation: API validation tests (signature, nonce, deadline, etc.)", + "error: Error handling tests", + "rest_api: REST API endpoint tests", +] diff --git a/sdk/async_api/account_balance.py b/sdk/async_api/account_balance.py new file mode 100644 index 00000000..90c3668b --- /dev/null +++ b/sdk/async_api/account_balance.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import model_serializer, model_validator, BaseModel, Field + +class AccountBalance(BaseModel): + account_id: int = Field(alias='''accountId''') + asset: str = Field() + real_balance: str = Field(alias='''realBalance''') + balance_deprecated: str = Field(alias='''balanceDEPRECATED''') + additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) + + @model_serializer(mode='wrap') + def custom_serializer(self, handler): + serialized_self = handler(self) + additional_properties = getattr(self, "additional_properties") + if additional_properties is not None: + for key, value in additional_properties.items(): + # Never overwrite existing values, to avoid clashes + if not key in serialized_self: + serialized_self[key] = value + + return serialized_self + + @model_validator(mode='before') + @classmethod + def unwrap_additional_properties(cls, data): + if not isinstance(data, dict): + data = data.model_dump() + json_properties = list(data.keys()) + known_object_properties = ['account_id', 'asset', 'real_balance', 'balance_deprecated', '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', 'asset', 'realBalance', 'balanceDEPRECATED', 'additionalProperties'] + additional_properties = data.get('additional_properties', {}) + for obj_key in unknown_object_properties: + if not known_json_properties.__contains__(obj_key): + additional_properties[obj_key] = data.pop(obj_key, None) + data['additional_properties'] = additional_properties + return data + diff --git a/sdk/async_api/account_balance_update_payload.py b/sdk/async_api/account_balance_update_payload.py new file mode 100644 index 00000000..18a8c5cc --- /dev/null +++ b/sdk/async_api/account_balance_update_payload.py @@ -0,0 +1,10 @@ +from __future__ import annotations +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.account_balance import AccountBalance +class AccountBalanceUpdatePayload(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 account balances''') + data: List[AccountBalance] = Field() diff --git a/sdk/async_api/depth.py b/sdk/async_api/depth.py new file mode 100644 index 00000000..95e7e783 --- /dev/null +++ b/sdk/async_api/depth.py @@ -0,0 +1,45 @@ +from __future__ import annotations +from typing import Any, List, Dict, Optional +from pydantic import model_serializer, model_validator, BaseModel, Field +from sdk.async_api.depth_type import DepthType +from sdk.async_api.level import Level +class Depth(BaseModel): + symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') + type: DepthType = Field(description='''Depth message type (SNAPSHOT = full book, UPDATE = single level change)''') + bids: List[Level] = Field(description='''Bid side levels aggregated by price, sorted descending by price''') + asks: List[Level] = Field(description='''Ask side levels aggregated by price, sorted ascending by price''') + updated_at: int = Field(alias='''updatedAt''') + additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) + + @model_serializer(mode='wrap') + def custom_serializer(self, handler): + serialized_self = handler(self) + additional_properties = getattr(self, "additional_properties") + if additional_properties is not None: + for key, value in additional_properties.items(): + # Never overwrite existing values, to avoid clashes + if not key in serialized_self: + serialized_self[key] = value + + return serialized_self + + @model_validator(mode='before') + @classmethod + def unwrap_additional_properties(cls, data): + if not isinstance(data, dict): + data = data.model_dump() + json_properties = list(data.keys()) + known_object_properties = ['symbol', 'type', 'bids', 'asks', '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', 'type', 'bids', 'asks', 'updatedAt', 'additionalProperties'] + additional_properties = data.get('additional_properties', {}) + for obj_key in unknown_object_properties: + if not known_json_properties.__contains__(obj_key): + additional_properties[obj_key] = data.pop(obj_key, None) + data['additional_properties'] = additional_properties + return data + diff --git a/sdk/async_api/depth_type.py b/sdk/async_api/depth_type.py new file mode 100644 index 00000000..eafab396 --- /dev/null +++ b/sdk/async_api/depth_type.py @@ -0,0 +1,5 @@ +from enum import Enum + +class DepthType(Enum): + SNAPSHOT = "SNAPSHOT" + UPDATE = "UPDATE" \ No newline at end of file diff --git a/sdk/async_api/level.py b/sdk/async_api/level.py new file mode 100644 index 00000000..35c972a5 --- /dev/null +++ b/sdk/async_api/level.py @@ -0,0 +1,41 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import model_serializer, model_validator, BaseModel, Field + +class Level(BaseModel): + px: str = Field() + qty: str = Field() + additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) + + @model_serializer(mode='wrap') + def custom_serializer(self, handler): + serialized_self = handler(self) + additional_properties = getattr(self, "additional_properties") + if additional_properties is not None: + for key, value in additional_properties.items(): + # Never overwrite existing values, to avoid clashes + if not key in serialized_self: + serialized_self[key] = value + + return serialized_self + + @model_validator(mode='before') + @classmethod + def unwrap_additional_properties(cls, data): + if not isinstance(data, dict): + data = data.model_dump() + json_properties = list(data.keys()) + known_object_properties = ['px', 'qty', '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 = ['px', 'qty', 'additionalProperties'] + additional_properties = data.get('additional_properties', {}) + for obj_key in unknown_object_properties: + if not known_json_properties.__contains__(obj_key): + additional_properties[obj_key] = data.pop(obj_key, None) + data['additional_properties'] = additional_properties + return data + diff --git a/sdk/async_api/market_depth_update_payload.py b/sdk/async_api/market_depth_update_payload.py new file mode 100644 index 00000000..9bef16a1 --- /dev/null +++ b/sdk/async_api/market_depth_update_payload.py @@ -0,0 +1,10 @@ +from __future__ import annotations +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.depth import Depth +class MarketDepthUpdatePayload(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 depth snapshots''') + data: Depth = Field() diff --git a/sdk/async_api/market_spot_execution_update_payload.py b/sdk/async_api/market_spot_execution_update_payload.py new file mode 100644 index 00000000..b3aa83cf --- /dev/null +++ b/sdk/async_api/market_spot_execution_update_payload.py @@ -0,0 +1,10 @@ +from __future__ import annotations +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 import SpotExecution +class MarketSpotExecutionUpdatePayload(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 executions''') + data: List[SpotExecution] = Field() diff --git a/sdk/async_api/market_summary.py b/sdk/async_api/market_summary.py index e9b11e22..66597c1d 100644 --- a/sdk/async_api/market_summary.py +++ b/sdk/async_api/market_summary.py @@ -3,7 +3,7 @@ from pydantic import model_serializer, model_validator, BaseModel, Field class MarketSummary(BaseModel): - symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)''') + 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''') diff --git a/sdk/async_api/order.py b/sdk/async_api/order.py index dd11fbe2..d259c8cd 100644 --- a/sdk/async_api/order.py +++ b/sdk/async_api/order.py @@ -7,11 +7,12 @@ from sdk.async_api.order_status import OrderStatus class Order(BaseModel): exchange_id: int = Field(alias='''exchangeId''') - symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)''') + symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') account_id: int = Field(alias='''accountId''') order_id: str = Field(alias='''orderId''') qty: Optional[str] = Field(default=None) exec_qty: Optional[str] = Field(default=None, alias='''execQty''') + 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''') @@ -41,13 +42,13 @@ def unwrap_additional_properties(cls, data): if not isinstance(data, dict): data = data.model_dump() json_properties = list(data.keys()) - known_object_properties = ['exchange_id', 'symbol', 'account_id', 'order_id', 'qty', 'exec_qty', 'side', 'limit_px', 'order_type', 'trigger_px', 'time_in_force', 'reduce_only', 'status', 'created_at', 'last_update_at', 'additional_properties'] + known_object_properties = ['exchange_id', 'symbol', 'account_id', 'order_id', 'qty', 'exec_qty', 'cum_qty', 'side', 'limit_px', 'order_type', 'trigger_px', 'time_in_force', 'reduce_only', 'status', 'created_at', 'last_update_at', 'additional_properties'] unknown_object_properties = [element for element in json_properties if element not in known_object_properties] # Ignore attempts that validate regular models, only when unknown input is used we add unwrap extensions if len(unknown_object_properties) == 0: return data - known_json_properties = ['exchangeId', 'symbol', 'accountId', 'orderId', 'qty', 'execQty', 'side', 'limitPx', 'orderType', 'triggerPx', 'timeInForce', 'reduceOnly', 'status', 'createdAt', 'lastUpdateAt', 'additionalProperties'] + known_json_properties = ['exchangeId', 'symbol', 'accountId', 'orderId', 'qty', 'execQty', 'cumQty', 'side', 'limitPx', 'orderType', 'triggerPx', 'timeInForce', 'reduceOnly', 'status', 'createdAt', 'lastUpdateAt', 'additionalProperties'] additional_properties = data.get('additional_properties', {}) for obj_key in unknown_object_properties: if not known_json_properties.__contains__(obj_key): diff --git a/sdk/async_api/perp_execution.py b/sdk/async_api/perp_execution.py index 66267500..b42a565d 100644 --- a/sdk/async_api/perp_execution.py +++ b/sdk/async_api/perp_execution.py @@ -5,7 +5,7 @@ from sdk.async_api.execution_type import ExecutionType class PerpExecution(BaseModel): exchange_id: int = Field(alias='''exchangeId''') - symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)''') + symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') account_id: int = Field(alias='''accountId''') qty: str = Field() side: Side = Field(description='''Order side (B = Buy/Bid, A = Ask/Sell)''') diff --git a/sdk/async_api/position.py b/sdk/async_api/position.py index 9a9276eb..0df6881c 100644 --- a/sdk/async_api/position.py +++ b/sdk/async_api/position.py @@ -4,7 +4,7 @@ from sdk.async_api.side import Side class Position(BaseModel): exchange_id: int = Field(alias='''exchangeId''') - symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)''') + symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') account_id: int = Field(alias='''accountId''') qty: str = Field() side: Side = Field(description='''Order side (B = Buy/Bid, A = Ask/Sell)''') diff --git a/sdk/async_api/price.py b/sdk/async_api/price.py index 8aaaba05..d26ed162 100644 --- a/sdk/async_api/price.py +++ b/sdk/async_api/price.py @@ -3,7 +3,7 @@ from pydantic import model_serializer, model_validator, BaseModel, Field class Price(BaseModel): - symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)''') + symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') oracle_price: str = Field(alias='''oraclePrice''') pool_price: Optional[str] = Field(default=None, alias='''poolPrice''') updated_at: int = Field(alias='''updatedAt''') diff --git a/sdk/async_api/spot_execution.py b/sdk/async_api/spot_execution.py new file mode 100644 index 00000000..46c13785 --- /dev/null +++ b/sdk/async_api/spot_execution.py @@ -0,0 +1,52 @@ +from __future__ import annotations +from typing import Any, Dict, Optional +from pydantic import model_serializer, model_validator, BaseModel, Field +from sdk.async_api.side import Side +from sdk.async_api.execution_type import ExecutionType +class SpotExecution(BaseModel): + exchange_id: Optional[int] = Field(default=None, alias='''exchangeId''') + symbol: str = Field(description='''Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)''') + account_id: int = Field(alias='''accountId''') + maker_account_id: int = Field(alias='''makerAccountId''') + order_id: Optional[str] = Field(description='''Order ID for the taker''', default=None, alias='''orderId''') + maker_order_id: Optional[str] = Field(description='''Order ID for the maker''', default=None, alias='''makerOrderId''') + side: Side = Field(description='''Order side (B = Buy/Bid, A = Ask/Sell)''') + qty: str = Field() + price: str = Field() + fee: str = Field() + type: ExecutionType = Field(description='''Type of execution''') + timestamp: int = Field() + additional_properties: Optional[dict[str, Any]] = Field(default=None, exclude=True) + + @model_serializer(mode='wrap') + def custom_serializer(self, handler): + serialized_self = handler(self) + additional_properties = getattr(self, "additional_properties") + if additional_properties is not None: + for key, value in additional_properties.items(): + # Never overwrite existing values, to avoid clashes + if not key in serialized_self: + serialized_self[key] = value + + return serialized_self + + @model_validator(mode='before') + @classmethod + def unwrap_additional_properties(cls, data): + if not isinstance(data, dict): + data = data.model_dump() + json_properties = list(data.keys()) + known_object_properties = ['exchange_id', 'symbol', 'account_id', 'maker_account_id', 'order_id', 'maker_order_id', 'side', 'qty', 'price', 'fee', 'type', 'timestamp', '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', 'makerAccountId', 'orderId', 'makerOrderId', 'side', 'qty', 'price', 'fee', 'type', 'timestamp', 'additionalProperties'] + additional_properties = data.get('additional_properties', {}) + for obj_key in unknown_object_properties: + if not known_json_properties.__contains__(obj_key): + additional_properties[obj_key] = data.pop(obj_key, None) + data['additional_properties'] = additional_properties + return data + diff --git a/sdk/async_api/wallet_spot_execution_update_payload.py b/sdk/async_api/wallet_spot_execution_update_payload.py new file mode 100644 index 00000000..538426bf --- /dev/null +++ b/sdk/async_api/wallet_spot_execution_update_payload.py @@ -0,0 +1,10 @@ +from __future__ import annotations +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 import SpotExecution +class WalletSpotExecutionUpdatePayload(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 executions''') + data: List[SpotExecution] = Field() diff --git a/sdk/open_api/__init__.py b/sdk/open_api/__init__.py index 830a329a..0700cf89 100644 --- a/sdk/open_api/__init__.py +++ b/sdk/open_api/__init__.py @@ -7,7 +7,7 @@ No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - The version of the OpenAPI document: 2.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -34,18 +34,24 @@ "ApiException", "Account", "AccountBalance", + "AccountType", "AssetDefinition", "CancelOrderRequest", "CancelOrderResponse", "CandleHistoryData", "CreateOrderRequest", "CreateOrderResponse", + "Depth", + "DepthType", "ExecutionType", "FeeTierParameters", "GlobalFeeParameters", + "Level", "LiquidityParameters", "MarketDefinition", "MarketSummary", + "MassCancelRequest", + "MassCancelResponse", "Order", "OrderStatus", "OrderType", @@ -61,6 +67,7 @@ "Side", "SpotExecution", "SpotExecutionList", + "SpotMarketDefinition", "TierType", "TimeInForce", "WalletConfiguration", @@ -87,18 +94,24 @@ # import models into sdk package from sdk.open_api.models.account import Account as Account from sdk.open_api.models.account_balance import AccountBalance as AccountBalance +from sdk.open_api.models.account_type import AccountType as AccountType from sdk.open_api.models.asset_definition import AssetDefinition as AssetDefinition from sdk.open_api.models.cancel_order_request import CancelOrderRequest as CancelOrderRequest from sdk.open_api.models.cancel_order_response import CancelOrderResponse as CancelOrderResponse from sdk.open_api.models.candle_history_data import CandleHistoryData as CandleHistoryData from sdk.open_api.models.create_order_request import CreateOrderRequest as CreateOrderRequest 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_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 +from sdk.open_api.models.level import Level as Level from sdk.open_api.models.liquidity_parameters import LiquidityParameters as LiquidityParameters from sdk.open_api.models.market_definition import MarketDefinition as MarketDefinition from sdk.open_api.models.market_summary import MarketSummary as MarketSummary +from sdk.open_api.models.mass_cancel_request import MassCancelRequest as MassCancelRequest +from sdk.open_api.models.mass_cancel_response import MassCancelResponse as MassCancelResponse from sdk.open_api.models.order import Order as Order from sdk.open_api.models.order_status import OrderStatus as OrderStatus from sdk.open_api.models.order_type import OrderType as OrderType @@ -114,6 +127,7 @@ 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_list import SpotExecutionList as SpotExecutionList +from sdk.open_api.models.spot_market_definition import SpotMarketDefinition as SpotMarketDefinition from sdk.open_api.models.tier_type import TierType as TierType from sdk.open_api.models.time_in_force import TimeInForce as TimeInForce from sdk.open_api.models.wallet_configuration import WalletConfiguration as WalletConfiguration diff --git a/sdk/open_api/api/market_data_api.py b/sdk/open_api/api/market_data_api.py index 2b7f790f..9043fc31 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -20,9 +20,11 @@ from typing import List, Optional 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.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_list import SpotExecutionList from sdk.open_api.api_client import ApiClient, RequestSerialized from sdk.open_api.api_response import ApiResponse @@ -340,6 +342,272 @@ def _get_candles_serialize( + @validate_call + async def get_market_depth( + 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, + ) -> Depth: + """Get market depth snapshot + + Returns an L2 order book snapshot with aggregated price levels for the specified market. + + :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_market_depth_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': "Depth", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def get_market_depth_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[Depth]: + """Get market depth snapshot + + Returns an L2 order book snapshot with aggregated price levels for the specified market. + + :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_market_depth_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': "Depth", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def get_market_depth_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 market depth snapshot + + Returns an L2 order book snapshot with aggregated price levels for the specified market. + + :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_market_depth_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': "Depth", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_market_depth_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='/market/{symbol}/depth', + 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_market_perp_executions( self, @@ -640,6 +908,306 @@ def _get_market_perp_executions_serialize( + @validate_call + 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 after this sequence number (for pagination)")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results before this sequence number (for pagination)")] = None, + _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, + ) -> SpotExecutionList: + """Get spot executions for market + + Returns up to 100 spot executions for a given market. + + :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) + :type symbol: str + :param start_time: Return results after this sequence number (for pagination) + :type start_time: int + :param end_time: Return results before this sequence number (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 + (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_market_spot_executions_serialize( + symbol=symbol, + start_time=start_time, + end_time=end_time, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "SpotExecutionList", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def get_market_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 after this sequence number (for pagination)")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results before this sequence number (for pagination)")] = None, + _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[SpotExecutionList]: + """Get spot executions for market + + Returns up to 100 spot executions for a given market. + + :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) + :type symbol: str + :param start_time: Return results after this sequence number (for pagination) + :type start_time: int + :param end_time: Return results before this sequence number (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 + (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_market_spot_executions_serialize( + symbol=symbol, + start_time=start_time, + end_time=end_time, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "SpotExecutionList", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def get_market_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 after this sequence number (for pagination)")] = None, + end_time: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Return results before this sequence number (for pagination)")] = None, + _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 spot executions for market + + Returns up to 100 spot executions for a given market. + + :param symbol: Trading symbol (e.g., BTCRUSDPERP) (required) + :type symbol: str + :param start_time: Return results after this sequence number (for pagination) + :type start_time: int + :param end_time: Return results before this sequence number (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 + (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_market_spot_executions_serialize( + symbol=symbol, + start_time=start_time, + end_time=end_time, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "SpotExecutionList", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_market_spot_executions_serialize( + self, + symbol, + start_time, + end_time, + _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 + 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 + + + # 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='/market/{symbol}/spotExecutions', + 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_market_summary( self, diff --git a/sdk/open_api/api/order_entry_api.py b/sdk/open_api/api/order_entry_api.py index 6ec88f63..8d89171f 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -16,10 +16,13 @@ from typing import Any, Dict, List, Optional, Tuple, Union from typing_extensions import Annotated +from typing import Optional from sdk.open_api.models.cancel_order_request import CancelOrderRequest from sdk.open_api.models.cancel_order_response import CancelOrderResponse from sdk.open_api.models.create_order_request import CreateOrderRequest from sdk.open_api.models.create_order_response import CreateOrderResponse +from sdk.open_api.models.mass_cancel_request import MassCancelRequest +from sdk.open_api.models.mass_cancel_response import MassCancelResponse from sdk.open_api.api_client import ApiClient, RequestSerialized from sdk.open_api.api_response import ApiResponse @@ -39,6 +42,285 @@ def __init__(self, api_client=None) -> None: self.api_client = api_client + @validate_call + async def cancel_all( + self, + mass_cancel_request: Optional[MassCancelRequest] = None, + _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, + ) -> MassCancelResponse: + """Cancel all orders + + Cancel all orders matching the specified filters (mass cancel) + + :param mass_cancel_request: + :type mass_cancel_request: MassCancelRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._cancel_all_serialize( + mass_cancel_request=mass_cancel_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "MassCancelResponse", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def cancel_all_with_http_info( + self, + mass_cancel_request: Optional[MassCancelRequest] = None, + _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[MassCancelResponse]: + """Cancel all orders + + Cancel all orders matching the specified filters (mass cancel) + + :param mass_cancel_request: + :type mass_cancel_request: MassCancelRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._cancel_all_serialize( + mass_cancel_request=mass_cancel_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "MassCancelResponse", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def cancel_all_without_preload_content( + self, + mass_cancel_request: Optional[MassCancelRequest] = None, + _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: + """Cancel all orders + + Cancel all orders matching the specified filters (mass cancel) + + :param mass_cancel_request: + :type mass_cancel_request: MassCancelRequest + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._cancel_all_serialize( + mass_cancel_request=mass_cancel_request, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "MassCancelResponse", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _cancel_all_serialize( + self, + mass_cancel_request, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + # process the query parameters + # process the header parameters + # process the form parameters + # process the body parameter + if mass_cancel_request is not None: + _body_params = mass_cancel_request + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/cancelAll', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def cancel_order( self, diff --git a/sdk/open_api/api/reference_data_api.py b/sdk/open_api/api/reference_data_api.py index 6daa620d..67a91075 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -22,6 +22,7 @@ from sdk.open_api.models.global_fee_parameters import GlobalFeeParameters from sdk.open_api.models.liquidity_parameters import LiquidityParameters from sdk.open_api.models.market_definition import MarketDefinition +from sdk.open_api.models.spot_market_definition import SpotMarketDefinition from sdk.open_api.api_client import ApiClient, RequestSerialized from sdk.open_api.api_response import ApiResponse @@ -1279,3 +1280,251 @@ def _get_market_definitions_serialize( ) + + + @validate_call + async def get_spot_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[SpotMarketDefinition]: + """Get spot market definitions + + + :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_spot_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[SpotMarketDefinition]", + '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_spot_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[SpotMarketDefinition]]: + """Get spot market definitions + + + :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_spot_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[SpotMarketDefinition]", + '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_spot_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 spot market definitions + + + :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_spot_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[SpotMarketDefinition]", + '400': "RequestError", + '500': "ServerError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_spot_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='/spotMarketDefinitions', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + diff --git a/sdk/open_api/api/specs_api.py b/sdk/open_api/api/specs_api.py index 7ca7587e..c73966c8 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.0.7 + The version of the OpenAPI document: 2.1.3 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 6355d6b7..9b1176d2 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/api_client.py b/sdk/open_api/api_client.py index 84865e1c..a0acfb69 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/configuration.py b/sdk/open_api/configuration.py index 4d8e1a3c..ea7c7709 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -496,7 +496,7 @@ def to_debug_report(self) -> str: return "Python SDK Debug Report:\n"\ "OS: {env}\n"\ "Python Version: {pyversion}\n"\ - "Version of the API: 2.0.7\n"\ + "Version of the API: 2.1.3\n"\ "SDK Package Version: 2.0.0".\ format(env=sys.platform, pyversion=sys.version) diff --git a/sdk/open_api/exceptions.py b/sdk/open_api/exceptions.py index 977694f8..05d1e7a7 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.0.7 + The version of the OpenAPI document: 2.1.3 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 bfc6751f..bc8bdeb6 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -16,18 +16,24 @@ # import models into model package from sdk.open_api.models.account import Account from sdk.open_api.models.account_balance import AccountBalance +from sdk.open_api.models.account_type import AccountType from sdk.open_api.models.asset_definition import AssetDefinition from sdk.open_api.models.cancel_order_request import CancelOrderRequest from sdk.open_api.models.cancel_order_response import CancelOrderResponse from sdk.open_api.models.candle_history_data import CandleHistoryData 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.depth import Depth +from sdk.open_api.models.depth_type import DepthType 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 +from sdk.open_api.models.level import Level from sdk.open_api.models.liquidity_parameters import LiquidityParameters from sdk.open_api.models.market_definition import MarketDefinition from sdk.open_api.models.market_summary import MarketSummary +from sdk.open_api.models.mass_cancel_request import MassCancelRequest +from sdk.open_api.models.mass_cancel_response import MassCancelResponse from sdk.open_api.models.order import Order from sdk.open_api.models.order_status import OrderStatus from sdk.open_api.models.order_type import OrderType @@ -43,6 +49,7 @@ from sdk.open_api.models.side import Side from sdk.open_api.models.spot_execution import SpotExecution 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.tier_type import TierType from sdk.open_api.models.time_in_force import TimeInForce from sdk.open_api.models.wallet_configuration import WalletConfiguration diff --git a/sdk/open_api/models/account.py b/sdk/open_api/models/account.py index b5bdf2ab..99ab0e12 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -20,6 +20,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictStr from typing import Any, ClassVar, Dict, List from typing_extensions import Annotated +from sdk.open_api.models.account_type import AccountType from typing import Optional, Set from typing_extensions import Self @@ -29,9 +30,9 @@ class Account(BaseModel): """ # noqa: E501 account_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="accountId") name: StrictStr - last_update_at: Annotated[int, Field(strict=True, ge=0)] = Field(alias="lastUpdateAt") + type: AccountType additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["accountId", "name", "lastUpdateAt"] + __properties: ClassVar[List[str]] = ["accountId", "name", "type"] model_config = ConfigDict( populate_by_name=True, @@ -93,7 +94,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "accountId": obj.get("accountId"), "name": obj.get("name"), - "lastUpdateAt": obj.get("lastUpdateAt") + "type": obj.get("type") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdk/open_api/models/account_balance.py b/sdk/open_api/models/account_balance.py index 9964739b..6d5494d4 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -30,8 +30,9 @@ class AccountBalance(BaseModel): account_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="accountId") asset: Annotated[str, Field(strict=True)] real_balance: Annotated[str, Field(strict=True)] = Field(alias="realBalance") + balance_deprecated: Annotated[str, Field(strict=True)] = Field(alias="balanceDEPRECATED") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["accountId", "asset", "realBalance"] + __properties: ClassVar[List[str]] = ["accountId", "asset", "realBalance", "balanceDEPRECATED"] @field_validator('asset') def asset_validate_regular_expression(cls, value): @@ -47,6 +48,13 @@ def real_balance_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") return value + @field_validator('balance_deprecated') + def balance_deprecated_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 + model_config = ConfigDict( populate_by_name=True, validate_assignment=True, @@ -107,7 +115,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "accountId": obj.get("accountId"), "asset": obj.get("asset"), - "realBalance": obj.get("realBalance") + "realBalance": obj.get("realBalance"), + "balanceDEPRECATED": obj.get("balanceDEPRECATED") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdk/open_api/models/account_type.py b/sdk/open_api/models/account_type.py new file mode 100644 index 00000000..fcb46728 --- /dev/null +++ b/sdk/open_api/models/account_type.py @@ -0,0 +1,38 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 2.1.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import json +from enum import Enum +from typing_extensions import Self + + +class AccountType(str, Enum): + """ + SPOT = account that can only trade spot, MAINPERP = main perp account, SUBPERP = sub perp account + """ + + """ + allowed enum values + """ + MAINPERP = 'MAINPERP' + SUBPERP = 'SUBPERP' + SPOT = 'SPOT' + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of AccountType from a JSON string""" + return cls(json.loads(json_str)) + + diff --git a/sdk/open_api/models/asset_definition.py b/sdk/open_api/models/asset_definition.py index b16d8154..56f6e3c5 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -17,8 +17,8 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, field_validator -from typing import Any, ClassVar, Dict, List +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List, Optional from typing_extensions import Annotated from typing import Optional, Set from typing_extensions import Self @@ -28,12 +28,14 @@ class AssetDefinition(BaseModel): AssetDefinition """ # noqa: E501 asset: Annotated[str, Field(strict=True)] - spot_market_symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)", alias="spotMarketSymbol") + spot_market_symbol: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)", alias="spotMarketSymbol") price_haircut: Annotated[str, Field(strict=True)] = Field(alias="priceHaircut") liquidation_discount: Annotated[str, Field(strict=True)] = Field(alias="liquidationDiscount") - timestamp: Annotated[int, Field(strict=True, ge=0)] + status: StrictStr = Field(description="Status of asset (ENABLED = deposits and withdrawals allowed, WITHDRAWAL_ONLY = only withdrawals allowed)") + decimals: Annotated[int, Field(strict=True, ge=0)] + display_decimals: Annotated[int, Field(strict=True, ge=0)] = Field(alias="displayDecimals") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["asset", "spotMarketSymbol", "priceHaircut", "liquidationDiscount", "timestamp"] + __properties: ClassVar[List[str]] = ["asset", "spotMarketSymbol", "priceHaircut", "liquidationDiscount", "status", "decimals", "displayDecimals"] @field_validator('asset') def asset_validate_regular_expression(cls, value): @@ -45,6 +47,9 @@ def asset_validate_regular_expression(cls, value): @field_validator('spot_market_symbol') def spot_market_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 @@ -63,6 +68,13 @@ def liquidation_discount_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") return value + @field_validator('status') + def status_validate_enum(cls, value): + """Validates the enum""" + if value not in set(['ENABLED', 'WITHDRAWAL_ONLY']): + raise ValueError("must be one of enum values ('ENABLED', 'WITHDRAWAL_ONLY')") + return value + model_config = ConfigDict( populate_by_name=True, validate_assignment=True, @@ -125,7 +137,9 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "spotMarketSymbol": obj.get("spotMarketSymbol"), "priceHaircut": obj.get("priceHaircut"), "liquidationDiscount": obj.get("liquidationDiscount"), - "timestamp": obj.get("timestamp") + "status": obj.get("status"), + "decimals": obj.get("decimals"), + "displayDecimals": obj.get("displayDecimals") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdk/open_api/models/cancel_order_request.py b/sdk/open_api/models/cancel_order_request.py index 97b6c988..1b8b1b36 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -17,8 +17,9 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr -from typing import Any, ClassVar, Dict, List +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated from typing import Optional, Set from typing_extensions import Self @@ -26,10 +27,25 @@ class CancelOrderRequest(BaseModel): """ CancelOrderRequest """ # noqa: E501 - order_id: StrictStr = Field(description="Order ID to cancel", alias="orderId") + 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") + 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)") 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") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["orderId", "signature"] + __properties: ClassVar[List[str]] = ["orderId", "clientOrderId", "accountId", "symbol", "signature", "nonce", "expiresAfter"] + + @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 model_config = ConfigDict( populate_by_name=True, @@ -90,7 +106,12 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "orderId": obj.get("orderId"), - "signature": obj.get("signature") + "clientOrderId": obj.get("clientOrderId"), + "accountId": obj.get("accountId"), + "symbol": obj.get("symbol"), + "signature": obj.get("signature"), + "nonce": obj.get("nonce"), + "expiresAfter": obj.get("expiresAfter") }) # 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 6c70cd33..610f2cd6 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -17,8 +17,8 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr -from typing import Any, ClassVar, Dict, List +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List, Optional from sdk.open_api.models.order_status import OrderStatus from typing import Optional, Set from typing_extensions import Self @@ -29,8 +29,9 @@ class CancelOrderResponse(BaseModel): """ # noqa: E501 status: OrderStatus order_id: StrictStr = Field(description="Cancelled order ID", alias="orderId") + client_order_id: Optional[StrictInt] = Field(default=None, description="Client-provided order ID echoed back from the request", alias="clientOrderId") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["status", "orderId"] + __properties: ClassVar[List[str]] = ["status", "orderId", "clientOrderId"] model_config = ConfigDict( populate_by_name=True, @@ -91,7 +92,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "status": obj.get("status"), - "orderId": obj.get("orderId") + "orderId": obj.get("orderId"), + "clientOrderId": obj.get("clientOrderId") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdk/open_api/models/candle_history_data.py b/sdk/open_api/models/candle_history_data.py index c7b56975..d6495b3f 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.0.7 + The version of the OpenAPI document: 2.1.3 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 31b4f088..0d02ad5d 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -30,7 +30,7 @@ class CreateOrderRequest(BaseModel): CreateOrderRequest """ # 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, ETHRUSD)") + 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") is_buy: StrictBool = Field(description="Whether this is a buy order", alias="isBuy") limit_px: Annotated[str, Field(strict=True)] = Field(alias="limitPx") @@ -43,8 +43,9 @@ class CreateOrderRequest(BaseModel): nonce: StrictStr = Field(description="Order nonce, see signatures and nonces section for more details.") signer_wallet: Annotated[str, Field(strict=True)] = Field(alias="signerWallet") 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"] + __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "accountId", "isBuy", "limitPx", "qty", "orderType", "timeInForce", "triggerPx", "reduceOnly", "signature", "nonce", "signerWallet", "expiresAfter", "clientOrderId"] @field_validator('symbol') def symbol_validate_regular_expression(cls, value): @@ -161,7 +162,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "signature": obj.get("signature"), "nonce": obj.get("nonce"), "signerWallet": obj.get("signerWallet"), - "expiresAfter": obj.get("expiresAfter") + "expiresAfter": obj.get("expiresAfter"), + "clientOrderId": obj.get("clientOrderId") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdk/open_api/models/create_order_response.py b/sdk/open_api/models/create_order_response.py index 25d4275b..68621ae8 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -17,8 +17,9 @@ import re # noqa: F401 import json -from pydantic import BaseModel, ConfigDict, Field, StrictStr +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated from sdk.open_api.models.order_status import OrderStatus from typing import Optional, Set from typing_extensions import Self @@ -28,9 +29,32 @@ class CreateOrderResponse(BaseModel): CreateOrderResponse """ # noqa: E501 status: OrderStatus + exec_qty: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="execQty") + cum_qty: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="cumQty") order_id: Optional[StrictStr] = Field(default=None, description="Created order ID (currently generated for all order types except IOC)", alias="orderId") + client_order_id: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, alias="clientOrderId") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["status", "orderId"] + __properties: ClassVar[List[str]] = ["status", "execQty", "cumQty", "orderId", "clientOrderId"] + + @field_validator('exec_qty') + def exec_qty_validate_regular_expression(cls, value): + """Validates the regular expression""" + if value is None: + return value + + if not re.match(r"^\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value + + @field_validator('cum_qty') + def cum_qty_validate_regular_expression(cls, value): + """Validates the regular expression""" + if value is None: + return value + + if not re.match(r"^\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value model_config = ConfigDict( populate_by_name=True, @@ -91,7 +115,10 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: _obj = cls.model_validate({ "status": obj.get("status"), - "orderId": obj.get("orderId") + "execQty": obj.get("execQty"), + "cumQty": obj.get("cumQty"), + "orderId": obj.get("orderId"), + "clientOrderId": obj.get("clientOrderId") }) # store additional fields in additional_properties for _key in obj.keys(): diff --git a/sdk/open_api/models/depth.py b/sdk/open_api/models/depth.py new file mode 100644 index 00000000..a41821d3 --- /dev/null +++ b/sdk/open_api/models/depth.py @@ -0,0 +1,132 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 2.1.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing import Any, ClassVar, Dict, List +from typing_extensions import Annotated +from sdk.open_api.models.depth_type import DepthType +from sdk.open_api.models.level import Level +from typing import Optional, Set +from typing_extensions import Self + +class Depth(BaseModel): + """ + Depth + """ # noqa: E501 + symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") + type: DepthType + bids: List[Level] = Field(description="Bid side levels aggregated by price, sorted descending by price") + asks: List[Level] = Field(description="Ask side levels aggregated by price, sorted ascending by price") + updated_at: Annotated[int, Field(strict=True, ge=0)] = Field(alias="updatedAt") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["symbol", "type", "bids", "asks", "updatedAt"] + + @field_validator('symbol') + def symbol_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not re.match(r"^[A-Za-z0-9]+$", value): + raise ValueError(r"must validate the regular expression /^[A-Za-z0-9]+$/") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of Depth from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in bids (list) + _items = [] + if self.bids: + for _item_bids in self.bids: + if _item_bids: + _items.append(_item_bids.to_dict()) + _dict['bids'] = _items + # override the default output from pydantic by calling `to_dict()` of each item in asks (list) + _items = [] + if self.asks: + for _item_asks in self.asks: + if _item_asks: + _items.append(_item_asks.to_dict()) + _dict['asks'] = _items + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of Depth from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "symbol": obj.get("symbol"), + "type": obj.get("type"), + "bids": [Level.from_dict(_item) for _item in obj["bids"]] if obj.get("bids") is not None else None, + "asks": [Level.from_dict(_item) for _item in obj["asks"]] if obj.get("asks") is not None else None, + "updatedAt": obj.get("updatedAt") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdk/open_api/models/depth_type.py b/sdk/open_api/models/depth_type.py new file mode 100644 index 00000000..36c2d8fe --- /dev/null +++ b/sdk/open_api/models/depth_type.py @@ -0,0 +1,37 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 2.1.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import json +from enum import Enum +from typing_extensions import Self + + +class DepthType(str, Enum): + """ + Depth message type (SNAPSHOT = full book, UPDATE = single level change) + """ + + """ + allowed enum values + """ + SNAPSHOT = 'SNAPSHOT' + UPDATE = 'UPDATE' + + @classmethod + def from_json(cls, json_str: str) -> Self: + """Create an instance of DepthType from a JSON string""" + return cls(json.loads(json_str)) + + diff --git a/sdk/open_api/models/execution_type.py b/sdk/open_api/models/execution_type.py index a07767a6..de70cc50 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/fee_tier_parameters.py b/sdk/open_api/models/fee_tier_parameters.py index 1ed41643..76a3bb02 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.0.7 + The version of the OpenAPI document: 2.1.3 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 e1c54223..390b32c5 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.0.7 + The version of the OpenAPI document: 2.1.3 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 new file mode 100644 index 00000000..c162f011 --- /dev/null +++ b/sdk/open_api/models/level.py @@ -0,0 +1,117 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 2.1.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from typing import Any, ClassVar, Dict, List +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class Level(BaseModel): + """ + Level + """ # noqa: E501 + px: Annotated[str, Field(strict=True)] + qty: Annotated[str, Field(strict=True)] + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["px", "qty"] + + @field_validator('px') + def px_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not re.match(r"^-?\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^-?\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value + + @field_validator('qty') + def qty_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not re.match(r"^\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of Level from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of Level from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "px": obj.get("px"), + "qty": obj.get("qty") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdk/open_api/models/liquidity_parameters.py b/sdk/open_api/models/liquidity_parameters.py index cc8938de..3faaf054 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -27,7 +27,7 @@ class LiquidityParameters(BaseModel): """ LiquidityParameters """ # noqa: E501 - symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)") + symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") depth: Annotated[str, Field(strict=True)] velocity_multiplier: Annotated[str, Field(strict=True)] = Field(alias="velocityMultiplier") additional_properties: Dict[str, Any] = {} diff --git a/sdk/open_api/models/market_definition.py b/sdk/open_api/models/market_definition.py index 9b0efaa8..89e0787e 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -27,7 +27,7 @@ class MarketDefinition(BaseModel): """ MarketDefinition """ # noqa: E501 - symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)") + symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") market_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="marketId") min_order_qty: Annotated[str, Field(strict=True)] = Field(alias="minOrderQty") qty_step_size: Annotated[str, Field(strict=True)] = Field(alias="qtyStepSize") diff --git a/sdk/open_api/models/market_summary.py b/sdk/open_api/models/market_summary.py index a28ef0c6..f2cabcd6 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -27,7 +27,7 @@ class MarketSummary(BaseModel): """ MarketSummary """ # noqa: E501 - symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)") + 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") diff --git a/sdk/open_api/models/mass_cancel_request.py b/sdk/open_api/models/mass_cancel_request.py new file mode 100644 index 00000000..4b8100fd --- /dev/null +++ b/sdk/open_api/models/mass_cancel_request.py @@ -0,0 +1,119 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 2.1.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List, Optional +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class MassCancelRequest(BaseModel): + """ + Request to cancel all orders matching the specified filters + """ # noqa: E501 + account_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="accountId") + 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") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["accountId", "symbol", "signature", "nonce", "expiresAfter"] + + @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 + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of MassCancelRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of MassCancelRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "accountId": obj.get("accountId"), + "symbol": obj.get("symbol"), + "signature": obj.get("signature"), + "nonce": obj.get("nonce"), + "expiresAfter": obj.get("expiresAfter") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdk/open_api/models/mass_cancel_response.py b/sdk/open_api/models/mass_cancel_response.py new file mode 100644 index 00000000..3bb0f25b --- /dev/null +++ b/sdk/open_api/models/mass_cancel_response.py @@ -0,0 +1,101 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 2.1.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field +from typing import Any, ClassVar, Dict, List +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class MassCancelResponse(BaseModel): + """ + MassCancelResponse + """ # noqa: E501 + cancelled_count: Annotated[int, Field(strict=True, ge=0)] = Field(alias="cancelledCount") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["cancelledCount"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of MassCancelResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of MassCancelResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "cancelledCount": obj.get("cancelledCount") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdk/open_api/models/order.py b/sdk/open_api/models/order.py index 3d0c2e78..fbb33636 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -32,11 +32,12 @@ class Order(BaseModel): Order """ # 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, ETHRUSD)") + 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") order_id: StrictStr = Field(alias="orderId") qty: Optional[Annotated[str, Field(strict=True)]] = None exec_qty: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="execQty") + cum_qty: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="cumQty") side: Side limit_px: Annotated[str, Field(strict=True)] = Field(alias="limitPx") order_type: OrderType = Field(alias="orderType") @@ -47,7 +48,7 @@ class Order(BaseModel): created_at: Annotated[int, Field(strict=True, ge=0)] = Field(alias="createdAt") last_update_at: Annotated[int, Field(strict=True, ge=0)] = Field(alias="lastUpdateAt") additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "accountId", "orderId", "qty", "execQty", "side", "limitPx", "orderType", "triggerPx", "timeInForce", "reduceOnly", "status", "createdAt", "lastUpdateAt"] + __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "accountId", "orderId", "qty", "execQty", "cumQty", "side", "limitPx", "orderType", "triggerPx", "timeInForce", "reduceOnly", "status", "createdAt", "lastUpdateAt"] @field_validator('symbol') def symbol_validate_regular_expression(cls, value): @@ -76,6 +77,16 @@ def exec_qty_validate_regular_expression(cls, value): raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") return value + @field_validator('cum_qty') + def cum_qty_validate_regular_expression(cls, value): + """Validates the regular expression""" + if value is None: + return value + + if not re.match(r"^\d+(\.\d+)?([eE][+-]?\d+)?$", value): + raise ValueError(r"must validate the regular expression /^\d+(\.\d+)?([eE][+-]?\d+)?$/") + return value + @field_validator('limit_px') def limit_px_validate_regular_expression(cls, value): """Validates the regular expression""" @@ -157,6 +168,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "orderId": obj.get("orderId"), "qty": obj.get("qty"), "execQty": obj.get("execQty"), + "cumQty": obj.get("cumQty"), "side": obj.get("side"), "limitPx": obj.get("limitPx"), "orderType": obj.get("orderType"), diff --git a/sdk/open_api/models/order_status.py b/sdk/open_api/models/order_status.py index 6b555a57..1a96a230 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.0.7 + The version of the OpenAPI document: 2.1.3 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 6fb71361..12233520 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/pagination_meta.py b/sdk/open_api/models/pagination_meta.py index 865f5947..b7fec1d0 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.0.7 + The version of the OpenAPI document: 2.1.3 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 8b63fcf5..5ad0b789 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -30,7 +30,7 @@ class PerpExecution(BaseModel): PerpExecution """ # 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, ETHRUSD)") + 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") qty: Annotated[str, Field(strict=True)] side: Side diff --git a/sdk/open_api/models/perp_execution_list.py b/sdk/open_api/models/perp_execution_list.py index bf3c4572..beae0da2 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.0.7 + The version of the OpenAPI document: 2.1.3 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 4d77ab26..80292a04 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -29,7 +29,7 @@ class Position(BaseModel): Position """ # 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, ETHRUSD)") + 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") qty: Annotated[str, Field(strict=True)] side: Side diff --git a/sdk/open_api/models/price.py b/sdk/open_api/models/price.py index ac490bcf..a8ca6122 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -27,7 +27,7 @@ class Price(BaseModel): """ Price """ # noqa: E501 - symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, ETHRUSD)") + symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") oracle_price: Annotated[str, Field(strict=True)] = Field(alias="oraclePrice") pool_price: Optional[Annotated[str, Field(strict=True)]] = Field(default=None, alias="poolPrice") updated_at: Annotated[int, Field(strict=True, ge=0)] = Field(alias="updatedAt") diff --git a/sdk/open_api/models/request_error.py b/sdk/open_api/models/request_error.py index 7d98436c..4ad3a817 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.0.7 + The version of the OpenAPI document: 2.1.3 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 3bfd2dea..d5f54d2f 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -32,6 +32,12 @@ class RequestErrorCode(str, Enum): INPUT_VALIDATION_ERROR = 'INPUT_VALIDATION_ERROR' CREATE_ORDER_OTHER_ERROR = 'CREATE_ORDER_OTHER_ERROR' CANCEL_ORDER_OTHER_ERROR = 'CANCEL_ORDER_OTHER_ERROR' + ORDER_DEADLINE_PASSED_ERROR = 'ORDER_DEADLINE_PASSED_ERROR' + ORDER_DEADLINE_TOO_HIGH_ERROR = 'ORDER_DEADLINE_TOO_HIGH_ERROR' + INVALID_NONCE_ERROR = 'INVALID_NONCE_ERROR' + UNAVAILABLE_MATCHING_ENGINE_ERROR = 'UNAVAILABLE_MATCHING_ENGINE_ERROR' + UNAUTHORIZED_SIGNATURE_ERROR = 'UNAUTHORIZED_SIGNATURE_ERROR' + NUMERIC_OVERFLOW_ERROR = 'NUMERIC_OVERFLOW_ERROR' @classmethod def from_json(cls, json_str: str) -> Self: diff --git a/sdk/open_api/models/server_error.py b/sdk/open_api/models/server_error.py index ce20b224..df634bac 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.0.7 + The version of the OpenAPI document: 2.1.3 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 523a5e99..966409b6 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.0.7 + The version of the OpenAPI document: 2.1.3 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 7ca297a7..4d3d894a 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.0.7 + The version of the OpenAPI document: 2.1.3 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 e9af15d0..0cba557c 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.0.7 + The version of the OpenAPI document: 2.1.3 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 @@ -29,17 +29,20 @@ class SpotExecution(BaseModel): """ SpotExecution """ # 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, ETHRUSD)") + exchange_id: Optional[Annotated[int, Field(strict=True, ge=0)]] = Field(default=None, 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") + maker_account_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="makerAccountId") + order_id: Optional[StrictStr] = Field(default=None, description="Order ID for the taker", alias="orderId") + maker_order_id: Optional[StrictStr] = Field(default=None, description="Order ID for the maker", alias="makerOrderId") side: Side qty: Annotated[str, Field(strict=True)] - price: Optional[Annotated[str, Field(strict=True)]] = None + price: Annotated[str, Field(strict=True)] fee: Annotated[str, Field(strict=True)] type: ExecutionType timestamp: Annotated[int, Field(strict=True, ge=0)] additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "accountId", "side", "qty", "price", "fee", "type", "timestamp"] + __properties: ClassVar[List[str]] = ["exchangeId", "symbol", "accountId", "makerAccountId", "orderId", "makerOrderId", "side", "qty", "price", "fee", "type", "timestamp"] @field_validator('symbol') def symbol_validate_regular_expression(cls, value): @@ -58,9 +61,6 @@ def qty_validate_regular_expression(cls, value): @field_validator('price') def price_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 @@ -133,6 +133,9 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "exchangeId": obj.get("exchangeId"), "symbol": obj.get("symbol"), "accountId": obj.get("accountId"), + "makerAccountId": obj.get("makerAccountId"), + "orderId": obj.get("orderId"), + "makerOrderId": obj.get("makerOrderId"), "side": obj.get("side"), "qty": obj.get("qty"), "price": obj.get("price"), diff --git a/sdk/open_api/models/spot_execution_list.py b/sdk/open_api/models/spot_execution_list.py index bbf71e37..a7022cea 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.0.7 + The version of the OpenAPI document: 2.1.3 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 new file mode 100644 index 00000000..26a218fb --- /dev/null +++ b/sdk/open_api/models/spot_market_definition.py @@ -0,0 +1,141 @@ +# coding: utf-8 + +""" + Reya DEX Trading API v2 + + No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + + The version of the OpenAPI document: 2.1.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List +from typing_extensions import Annotated +from typing import Optional, Set +from typing_extensions import Self + +class SpotMarketDefinition(BaseModel): + """ + SpotMarketDefinition + """ # noqa: E501 + symbol: Annotated[str, Field(strict=True)] = Field(description="Trading symbol (e.g., BTCRUSDPERP, WETHRUSD)") + market_id: Annotated[int, Field(strict=True, ge=0)] = Field(alias="marketId") + base_asset: StrictStr = Field(description="Base asset symbol", alias="baseAsset") + quote_asset: StrictStr = Field(description="Quote asset symbol", alias="quoteAsset") + min_order_qty: Annotated[str, Field(strict=True)] = Field(alias="minOrderQty") + qty_step_size: Annotated[str, Field(strict=True)] = Field(alias="qtyStepSize") + tick_size: Annotated[str, Field(strict=True)] = Field(alias="tickSize") + additional_properties: Dict[str, Any] = {} + __properties: ClassVar[List[str]] = ["symbol", "marketId", "baseAsset", "quoteAsset", "minOrderQty", "qtyStepSize", "tickSize"] + + @field_validator('symbol') + def symbol_validate_regular_expression(cls, value): + """Validates the regular expression""" + if not re.match(r"^[A-Za-z0-9]+$", value): + raise ValueError(r"must validate the regular expression /^[A-Za-z0-9]+$/") + return value + + @field_validator('min_order_qty') + def min_order_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('qty_step_size') + def qty_step_size_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('tick_size') + def tick_size_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 + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of SpotMarketDefinition from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + * Fields in `self.additional_properties` are added to the output dict. + """ + excluded_fields: Set[str] = set([ + "additional_properties", + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # puts key-value pairs in additional_properties in the top level + if self.additional_properties is not None: + for _key, _value in self.additional_properties.items(): + _dict[_key] = _value + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of SpotMarketDefinition from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "symbol": obj.get("symbol"), + "marketId": obj.get("marketId"), + "baseAsset": obj.get("baseAsset"), + "quoteAsset": obj.get("quoteAsset"), + "minOrderQty": obj.get("minOrderQty"), + "qtyStepSize": obj.get("qtyStepSize"), + "tickSize": obj.get("tickSize") + }) + # store additional fields in additional_properties + for _key in obj.keys(): + if _key not in cls.__properties: + _obj.additional_properties[_key] = obj.get(_key) + + return _obj + + diff --git a/sdk/open_api/models/tier_type.py b/sdk/open_api/models/tier_type.py index 917c1ba7..4d5c0dad 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.0.7 + The version of the OpenAPI document: 2.1.3 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 cd35a60a..8bbd275b 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/open_api/models/wallet_configuration.py b/sdk/open_api/models/wallet_configuration.py index 7b7949f9..26697a68 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.0.7 + The version of the OpenAPI document: 2.1.3 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 4b4c2d6b..251e28d4 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.0.7 + The version of the OpenAPI document: 2.1.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/sdk/reya_rest_api/__init__.py b/sdk/reya_rest_api/__init__.py index 4121c68d..ec72a333 100644 --- a/sdk/reya_rest_api/__init__.py +++ b/sdk/reya_rest_api/__init__.py @@ -6,6 +6,6 @@ """ from sdk.reya_rest_api.client import ReyaTradingClient -from sdk.reya_rest_api.config import TradingConfig +from sdk.reya_rest_api.config import TradingConfig, get_spot_config -__all__ = ["ReyaTradingClient", "TradingConfig"] +__all__ = ["ReyaTradingClient", "TradingConfig", "get_spot_config"] diff --git a/sdk/reya_rest_api/auth/signatures.py b/sdk/reya_rest_api/auth/signatures.py index e62762e8..06e9477e 100644 --- a/sdk/reya_rest_api/auth/signatures.py +++ b/sdk/reya_rest_api/auth/signatures.py @@ -174,7 +174,7 @@ def sign_raw_order( else f"0x{signed_message.signature.hex()}" ) - def sign_cancel_order(self, order_id: str) -> str: + def sign_cancel_order_perps(self, order_id: str) -> str: """ Sign an order cancellation message using personal_sign. @@ -205,3 +205,137 @@ def sign_cancel_order(self, order_id: str) -> str: if signed_message.signature.hex().startswith("0x") else f"0x{signed_message.signature.hex()}" ) + + def sign_cancel_order_spot( + self, + account_id: int, + market_id: int, + order_id: int, + client_order_id: int, + nonce: int, + deadline: int, + ) -> str: + """ + Sign an order cancellation message using EIP-712 (for SPOT orders). + + 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 + """ + # 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"}, + {"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"}, + ], + } + + # Create the message to sign + message = { + "verifyingChainId": self._chain_id, + "deadline": deadline, + "cancel": { + "accountId": account_id, + "marketId": market_id, + "orderId": order_id, + "clOrdId": client_order_id, + "nonce": nonce, + }, + } + + # 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()}" + ) + + def sign_mass_cancel( + self, + account_id: int, + market_id: int, + nonce: int, + deadline: int, + ) -> str: + """ + Sign a mass cancel request using EIP-712 (for SPOT orders). + + 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) + types = { + "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"}, + ], + } + + # Create the message to sign + message = { + "verifyingChainId": self._chain_id, + "deadline": deadline, + "massCancel": { + "accountId": account_id, + "marketId": market_id, + "nonce": nonce, + }, + } + + # 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()}" + ) diff --git a/sdk/reya_rest_api/client.py b/sdk/reya_rest_api/client.py index 68883145..ebe3a312 100644 --- a/sdk/reya_rest_api/client.py +++ b/sdk/reya_rest_api/client.py @@ -7,6 +7,7 @@ from typing import Optional import logging +import threading import time from decimal import Decimal @@ -24,6 +25,8 @@ 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.market_definition import MarketDefinition +from sdk.open_api.models.mass_cancel_request import MassCancelRequest +from sdk.open_api.models.mass_cancel_response import MassCancelResponse from sdk.open_api.models.order import Order from sdk.open_api.models.order_type import OrderType from sdk.open_api.models.perp_execution_list import PerpExecutionList @@ -38,7 +41,8 @@ from .models.orders import LimitOrderParameters, TriggerOrderParameters CONDITIONAL_ORDER_DEADLINE = 10**18 -DEFAULT_DEADLINE_MS = 5000 +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 @@ -60,28 +64,18 @@ class ReyaTradingClient: with resources for managing orders and accounts. """ - def __init__( - self, - config: Optional[TradingConfig] = None, - private_key: Optional[str] = None, - api_url: Optional[str] = None, - chain_id: Optional[int] = None, - account_id: Optional[int] = None, - ): + # Class-level nonce tracking per wallet address (shared across all 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 - private_key: Optional private key for signing requests - api_url: Optional API URL override - chain_id: Optional chain ID override - account_id: Optional default account ID - - If config is provided, it will be used as-is. - If config is not provided, it will be loaded from environment variables. - If any of private_key, api_url, or chain_id are provided, they will override - the corresponding values in the config. + 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] = {} @@ -90,18 +84,8 @@ def __init__( # Setup logging self.logger = logging.getLogger("reya_trading.client") - # Get config from environment if not provided - self._config = config or get_config() - - # Override config values if provided - if private_key: - self._config.private_key = private_key - if api_url: - self._config.api_url = api_url - if chain_id: - self._config.chain_id = chain_id - if account_id: - self._config.account_id = account_id + # 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) @@ -129,10 +113,59 @@ async def start(self) -> None: await self._load_market_definitions() 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} + 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 - self.logger.info(f"Loaded {len(self._symbol_to_market_id)} market definitions") + 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") + + 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. + + 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. + """ + 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.""" @@ -144,6 +177,9 @@ 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 @property @@ -171,6 +207,19 @@ 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).""" @@ -215,9 +264,14 @@ async def create_limit_order(self, params: LimitOrderParameters) -> CreateOrderR if self.config.account_id is None: raise ValueError("Account ID is required for order signing") - nonce = self._signature_generator.create_orders_gateway_nonce( - self.config.account_id, market_id, int(time.time_ns() / 1000000) - ) + # For spot markets, use monotonically increasing nonce (fits in uint64) + # For perp markets, use 32-byte nonce + if self._is_spot_market(params.symbol): + 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) + ) inputs = self._signature_generator.encode_inputs_limit_order( is_buy=params.is_buy, @@ -225,28 +279,45 @@ async def create_limit_order(self, params: LimitOrderParameters) -> CreateOrderR qty=Decimal(params.qty), ) + # Determine deadline based on order type and market type if params.time_in_force != TimeInForce.IOC: - deadline = CONDITIONAL_ORDER_DEADLINE + # For GTC orders: use real timestamp for spot markets, 10^18 for perp markets + if self._is_spot_market(params.symbol): + deadline = int(time.time()) + GTC_DEADLINE_S # 24 hours for GTC spot orders + else: + deadline = CONDITIONAL_ORDER_DEADLINE elif params.expires_after is None: - deadline = int(time.time() * 1000) + DEFAULT_DEADLINE_MS + # For IOC orders, use default deadline + deadline = int(time.time()) + DEFAULT_DEADLINE_S else: deadline = params.expires_after - order_type_int = ( - OrdersGatewayOrderType.LIMIT_ORDER - if params.time_in_force == TimeInForce.GTC - else ( - OrdersGatewayOrderType.REDUCE_ONLY_MARKET_ORDER - if params.reduce_only is True - else OrdersGatewayOrderType.MARKET_ORDER + # For spot markets, ALWAYS use LIMIT_ORDER_SPOT (6) regardless of timeInForce + # The blockchain only supports matching LimitOrderSpot against LimitOrderSpot for spot trades + # TimeInForce behavior is encoded in the inputs field, not in the orderType + if self._is_spot_market(params.symbol): + order_type_int = OrdersGatewayOrderType.LIMIT_ORDER_SPOT + else: + # For perp markets, use orderType based on timeInForce + order_type_int = ( + OrdersGatewayOrderType.LIMIT_ORDER + if params.time_in_force == TimeInForce.GTC + else ( + OrdersGatewayOrderType.REDUCE_ONLY_MARKET_ORDER + if params.reduce_only is True + else OrdersGatewayOrderType.MARKET_ORDER + ) ) - ) + + # For spot markets, counterparty_account_ids should be empty [] + # Spot trades are matched against an orderbook, rather than directly against the pool. + counterparty_ids = [] if self._is_spot_market(params.symbol) else [self.config.pool_account_id] signature = self._signature_generator.sign_raw_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], + counterparty_account_ids=counterparty_ids, order_type=order_type_int, inputs=inputs, deadline=deadline, @@ -257,6 +328,13 @@ async def create_limit_order(self, params: LimitOrderParameters) -> CreateOrderR if self.config.account_id is None: raise ValueError("Account ID is required for order creation") + # Only include expiresAfter for IOC orders and spot markets + # GTC perp orders don't support expiresAfter + is_ioc_or_spot = params.time_in_force == TimeInForce.IOC or self._is_spot_market(params.symbol) + + # reduceOnly is only supported for perp IOC orders + is_perp_ioc = params.time_in_force == TimeInForce.IOC and not self._is_spot_market(params.symbol) + order_request = CreateOrderRequest( accountId=self.config.account_id, symbol=params.symbol, @@ -266,11 +344,12 @@ async def create_limit_order(self, params: LimitOrderParameters) -> CreateOrderR qty=params.qty, orderType=OrderType.LIMIT, timeInForce=params.time_in_force, - expiresAfter=deadline if params.time_in_force == TimeInForce.IOC else None, - reduceOnly=params.reduce_only, + expiresAfter=deadline if is_ioc_or_spot else None, + reduceOnly=params.reduce_only if is_perp_ioc else None, signature=signature, nonce=str(nonce), signerWallet=self.signer_wallet_address, + clientOrderId=params.client_order_id, ) response = await self.orders.create_order(create_order_request=order_request) @@ -289,6 +368,10 @@ async def create_trigger_order(self, params: TriggerOrderParameters) -> CreateOr """ # Resolve symbol to market_id + + if self._is_spot_market(params.symbol): + raise ValueError("Trigger orders are not supported for spot markets") + market_id = self._get_market_id_from_symbol(params.symbol) if self._signature_generator is None: @@ -348,30 +431,164 @@ async def create_trigger_order(self, params: TriggerOrderParameters) -> CreateOr return response - async def cancel_order(self, order_id: str) -> CancelOrderResponse: + async def cancel_order( + self, + order_id: Optional[str] = None, + symbol: Optional[str] = None, + account_id: Optional[int] = 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. + Args: - order_id: ID of the order to cancel + 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 the API returns an error + 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 """ if self._signature_generator is None: raise ValueError("Private key is required for cancelling orders") - # Sign the cancellation request - signature = self._signature_generator.sign_cancel_order(order_id) - - cancel_order_request = CancelOrderRequest(orderId=order_id, signature=signature) + # Determine if this is a spot market order + is_spot_order = symbol and "RUSD" in symbol and "PERP" not in symbol + + # For spot markets, symbol and account_id are required + 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})") + # For spot markets: must provide at least one of order_id or client_order_id + # If both are provided, the API will prefer order_id + if not order_id and not client_order_id: + raise ValueError("For spot orders, must provide either order_id or client_order_id") + else: + # For perp markets, order_id is required + if not order_id: + raise ValueError("order_id is required for perp market order cancellation") + + if is_spot_order: + # Type assertions after validation (symbol and account_id are validated above) + assert symbol is not None + assert account_id is not None + + # Get market_id from symbol + market_id = self._get_market_id_from_symbol(symbol) + + # Generate monotonically increasing nonce + nonce = self._get_next_nonce() + + # Generate deadline (current time + 5 seconds, in seconds) + deadline = int(time.time()) + DEFAULT_DEADLINE_S + + # For EIP-712 signature, we need both orderId and clOrdId + # If one is not provided, use 0 as placeholder + order_id_int = int(order_id) if order_id else 0 + client_order_id_int = client_order_id if client_order_id is not None else 0 + + # Generate EIP-712 signature for SPOT orders + 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: + # Type assertion after validation (order_id is validated above for perp) + assert order_id is not None + signature = self._signature_generator.sign_cancel_order_perps(order_id) + nonce = None + deadline = None + + cancel_order_request = CancelOrderRequest( + 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, + ) response = await self.orders.cancel_order(cancel_order_request) return response + async def mass_cancel( + self, + symbol: str, + 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 + """ + if self._signature_generator is None: + raise ValueError("Private key is required for mass cancel") + + # Verify this is a spot market + if not self._is_spot_market(symbol): + raise ValueError( + f"Mass cancel is only supported for spot markets. " f"Symbol '{symbol}' appears to be a perp market." + ) + + # Use config account_id if not provided + 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") + + # Get market_id from symbol + market_id = self._get_market_id_from_symbol(symbol) + + # Generate monotonically increasing nonce + nonce = self._get_next_nonce() + + # Generate deadline (current time + 5 seconds, in seconds) + deadline = int(time.time()) + DEFAULT_DEADLINE_S + + # Generate EIP-712 signature for mass cancel + signature = self._signature_generator.sign_mass_cancel( + account_id=account_id, + market_id=market_id, + nonce=nonce, + deadline=deadline, + ) + + mass_cancel_request = MassCancelRequest( + accountId=account_id, + symbol=symbol, + signature=signature, + nonce=str(nonce), + expiresAfter=deadline, + ) + + response = await self.orders.cancel_all(mass_cancel_request) + return response + async def get_positions(self, wallet_address: Optional[str] = None) -> list[Position]: """ Get positions for a wallet address asynchronously. diff --git a/sdk/reya_rest_api/config.py b/sdk/reya_rest_api/config.py index 89c66af5..a8e69da7 100644 --- a/sdk/reya_rest_api/config.py +++ b/sdk/reya_rest_api/config.py @@ -1,5 +1,5 @@ """ -Configuration settings for the Reya Trading API. +Configuration settings for the Reya Trading API.x """ from typing import Optional @@ -59,11 +59,11 @@ def from_env(cls) -> "TradingConfig": else: default_api_url = "https://api-cronos.reya.xyz/v2" - # Require OWNER_WALLET_ADDRESS - owner_wallet_address = os.environ.get("OWNER_WALLET_ADDRESS") + # Require PERP_WALLET_ADDRESS_1 + owner_wallet_address = os.environ.get("PERP_WALLET_ADDRESS_1") if not owner_wallet_address: raise ValueError( - "OWNER_WALLET_ADDRESS environment variable is required. " + "PERP_WALLET_ADDRESS_1 environment variable is required. " "This should be the wallet address whose data you want to query." ) @@ -71,11 +71,69 @@ def from_env(cls) -> "TradingConfig": 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("PRIVATE_KEY"), - account_id=(int(os.environ["ACCOUNT_ID"]) if "ACCOUNT_ID" in os.environ else None), + 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), + ) + + @classmethod + def from_env_spot(cls, account_number: int = 1) -> "TradingConfig": + """Create a config instance from SPOT environment variables. + + Args: + account_number: Which spot account to use (1 or 2) + + Returns: + TradingConfig configured for the specified SPOT account + + Raises: + ValueError: If required environment variables are not set + """ + load_dotenv() + + if account_number not in (1, 2): + raise ValueError(f"account_number must be 1 or 2, got {account_number}") + + chain_id = int(os.environ.get("CHAIN_ID", MAINNET_CHAIN_ID)) + + # Get API URL based on environment (mainnet or 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" + + # Get SPOT account credentials + owner_wallet_address = os.environ.get(f"SPOT_WALLET_ADDRESS_{account_number}") + if not owner_wallet_address: + raise ValueError( + f"SPOT_WALLET_ADDRESS_{account_number} environment variable is required. " + "This should be the wallet address whose data you want to query." + ) + + private_key = os.environ.get(f"SPOT_PRIVATE_KEY_{account_number}") + account_id_str = os.environ.get(f"SPOT_ACCOUNT_ID_{account_number}") + account_id = int(account_id_str) if account_id_str else None + + 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, ) def get_config() -> TradingConfig: """Get configuration from environment.""" return TradingConfig.from_env() + + +def get_spot_config(account_number: int = 1) -> TradingConfig: + """Get SPOT account configuration from environment. + + Args: + account_number: Which spot account to use (1 or 2) + + Returns: + TradingConfig configured for the specified SPOT account + """ + return TradingConfig.from_env_spot(account_number) diff --git a/sdk/reya_rest_api/constants/enums.py b/sdk/reya_rest_api/constants/enums.py index 0ecd5f55..68102ec9 100644 --- a/sdk/reya_rest_api/constants/enums.py +++ b/sdk/reya_rest_api/constants/enums.py @@ -13,3 +13,5 @@ class OrdersGatewayOrderType(IntEnum): 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 0f35317d..91988417 100644 --- a/sdk/reya_rest_api/models/orders.py +++ b/sdk/reya_rest_api/models/orders.py @@ -17,6 +17,7 @@ class LimitOrderParameters: time_in_force: time_in_force.TimeInForce reduce_only: Optional[bool] = None expires_after: Optional[int] = None + client_order_id: Optional[int] = None def to_dict(self) -> dict[str, Any]: return { @@ -27,6 +28,7 @@ def to_dict(self) -> dict[str, Any]: "reduce_only": self.reduce_only, "expires_after": self.expires_after, "time_in_force": self.time_in_force, + "client_order_id": self.client_order_id, } diff --git a/sdk/reya_rpc/config.py b/sdk/reya_rpc/config.py index 67a290bf..1a886d06 100644 --- a/sdk/reya_rpc/config.py +++ b/sdk/reya_rpc/config.py @@ -27,14 +27,14 @@ def get_network_addresses(chain_id: int) -> dict: } elif chain_id == 89346162: return { - "rpc_url": "https://bartio.rpc.berachain.com/", + "rpc_url": "https://rpc.reya-cronos.gelato.digital", "passive_pool_account_id": 2, "exchange_id": 1, - "core_address": "0x77C9F40938db89E78e2071d007Dca31f07C59e2C", - "multicall_address": "0x90C9c8047fE5CF0e59E9D0e80a2D46A2AD45b14B", - "oracle_adapter_address": "0x2E3Dd3DA71c31E2C6ae87b20A6e4Ae85a01c30b1", - "passive_perp_address": "0xE2d0E8a9E2B8E7F4cE5BBfF0a96b9e8B66b72AF8", - "passive_pool_address": "0x0A97C52C1bE9Cff2c84f9dFeCd8e4c6f6FabAb2f", + "core_address": "0xC6fB022962e1426F4e0ec9D2F8861c57926E9f72", + "multicall_address": "0x5abde4F0aF8Eaf3c9967f7fA126E59A103357b5C", + "oracle_adapter_address": "0xc501A2356703CD351703D68963c6F4136120f7CF", + "passive_perp_address": "0x9EC177fed042eF2307928BE2F5CDbf663B20244B", + "passive_pool_address": "0x9A3A664987b88790A6FDC1632e3b607813fd94fF", "rusd_address": "0x9DE724e7b3facF87Ce39465D3D712717182e3e55", "periphery_address": "0x94ccAe812f1647696754412082dd6684C2366A7f", "usdc_address": "0xfA27c7c6051344263533cc365274d9569b0272A8", @@ -81,7 +81,7 @@ def get_config() -> dict: load_dotenv() chain_id = int(os.environ["CHAIN_ID"]) - private_key = os.environ["PRIVATE_KEY"] + private_key = os.environ["PERP_PRIVATE_KEY_1"] # Get network-specific addresses network_config = get_network_addresses(chain_id) diff --git a/sdk/reya_websocket/__init__.py b/sdk/reya_websocket/__init__.py index 575dc605..7783ebd4 100644 --- a/sdk/reya_websocket/__init__.py +++ b/sdk/reya_websocket/__init__.py @@ -1,6 +1,13 @@ from sdk.reya_websocket.resources.market import MarketResource from sdk.reya_websocket.resources.prices import PricesResource from sdk.reya_websocket.resources.wallet import WalletResource -from sdk.reya_websocket.socket import ReyaSocket +from sdk.reya_websocket.socket import ReyaSocket, WebSocketDataError, WebSocketMessage -__all__ = ["ReyaSocket", "MarketResource", "WalletResource", "PricesResource"] +__all__ = [ + "ReyaSocket", + "WebSocketMessage", + "WebSocketDataError", + "MarketResource", + "WalletResource", + "PricesResource", +] diff --git a/sdk/reya_websocket/resources/market.py b/sdk/reya_websocket/resources/market.py index 7fa26b1e..a5ac2735 100644 --- a/sdk/reya_websocket/resources/market.py +++ b/sdk/reya_websocket/resources/market.py @@ -24,6 +24,8 @@ def __init__(self, socket: "ReyaSocket"): self._all_markets_summary = AllMarketsSummaryResource(socket) self._market_summary = MarketSummaryResource(socket) self._market_perp_executions = MarketPerpExecutionsResource(socket) + self._market_spot_executions = MarketSpotExecutionsResource(socket) + self._market_depth = MarketDepthResource(socket) @property def all_markets_summary(self) -> "AllMarketsSummaryResource": @@ -52,6 +54,28 @@ def perp_executions(self, symbol: str) -> "MarketPerpExecutionsSubscription": """ return self._market_perp_executions.for_symbol(symbol) + def spot_executions(self, symbol: str) -> "MarketSpotExecutionsSubscription": + """Get spot executions for a specific symbol. + + Args: + symbol: The trading symbol (e.g., "WETHRUSD", "BTCRUSD"). + + Returns: + A subscription object for the specified market spot executions. + """ + return self._market_spot_executions.for_symbol(symbol) + + def depth(self, symbol: str) -> "MarketDepthSubscription": + """Get L2 market depth (orderbook) for a specific symbol. + + Args: + symbol: The trading symbol (e.g., "BTCRUSDPERP", "WETHRUSD"). + + Returns: + A subscription object for the specified market depth. + """ + return self._market_depth.for_symbol(symbol) + class AllMarketsSummaryResource(SubscribableResource): """Resource for accessing all markets summary data.""" @@ -181,3 +205,103 @@ def subscribe(self, batched: bool = False) -> None: def unsubscribe(self) -> None: """Unsubscribe from market perpetual executions.""" self.socket.send_unsubscribe(channel=self.path) + + +class MarketSpotExecutionsResource(SubscribableParameterizedResource): + """Resource for accessing market spot executions.""" + + def __init__(self, socket: "ReyaSocket"): + """Initialize the market spot executions resource. + + Args: + socket: The WebSocket connection to use for this resource. + """ + super().__init__(socket, "/v2/market/{symbol}/spotExecutions") + + def for_symbol(self, symbol: str) -> "MarketSpotExecutionsSubscription": + """Create a subscription for a specific market's spot executions. + + Args: + symbol: The trading symbol (e.g., "WETHRUSD", "BTCRUSD"). + + Returns: + A subscription object for the specified market spot executions. + """ + return MarketSpotExecutionsSubscription(self.socket, symbol) + + +class MarketSpotExecutionsSubscription: + """Manages a subscription to market spot executions for a specific symbol.""" + + def __init__(self, socket: "ReyaSocket", symbol: str): + """Initialize a market spot executions subscription. + + Args: + socket: The WebSocket connection to use for this subscription. + symbol: The trading symbol (e.g., "WETHRUSD", "BTCRUSD"). + """ + self.socket = socket + self.symbol = symbol + self.path = f"/v2/market/{symbol}/spotExecutions" + + def subscribe(self, batched: bool = False) -> None: + """Subscribe to market spot executions. + + Args: + batched: Whether to receive updates in batches. + """ + self.socket.send_subscribe(channel=self.path, batched=batched) + + def unsubscribe(self) -> None: + """Unsubscribe from market spot executions.""" + self.socket.send_unsubscribe(channel=self.path) + + +class MarketDepthResource(SubscribableParameterizedResource): + """Resource for accessing market depth (L2 orderbook).""" + + def __init__(self, socket: "ReyaSocket"): + """Initialize the market depth resource. + + Args: + socket: The WebSocket connection to use for this resource. + """ + super().__init__(socket, "/v2/market/{symbol}/depth") + + def for_symbol(self, symbol: str) -> "MarketDepthSubscription": + """Create a subscription for a specific market's depth. + + Args: + symbol: The trading symbol (e.g., "WETHRUSD", "BTCRUSD"). + + Returns: + A subscription object for the specified market depth. + """ + return MarketDepthSubscription(self.socket, symbol) + + +class MarketDepthSubscription: + """Manages a subscription to market depth for a specific symbol.""" + + def __init__(self, socket: "ReyaSocket", symbol: str): + """Initialize a market depth subscription. + + Args: + socket: The WebSocket connection to use for this subscription. + symbol: The trading symbol (e.g., "WETHRUSD", "BTCRUSD"). + """ + self.socket = socket + self.symbol = symbol + self.path = f"/v2/market/{symbol}/depth" + + def subscribe(self, batched: bool = False) -> None: + """Subscribe to market depth. + + Args: + batched: Whether to receive updates in batches. + """ + self.socket.send_subscribe(channel=self.path, batched=batched) + + def unsubscribe(self) -> None: + """Unsubscribe from market depth.""" + self.socket.send_unsubscribe(channel=self.path) diff --git a/sdk/reya_websocket/resources/wallet.py b/sdk/reya_websocket/resources/wallet.py index a10e9214..9d1960a5 100644 --- a/sdk/reya_websocket/resources/wallet.py +++ b/sdk/reya_websocket/resources/wallet.py @@ -20,6 +20,8 @@ def __init__(self, socket: "ReyaSocket"): self.socket = socket self._positions = WalletPositionsResource(socket) self._perp_executions = WalletPerpExecutionsResource(socket) + self._spot_executions = WalletSpotExecutionsResource(socket) + self._balances = WalletBalancesResource(socket) self._order_changes = WalletOrderChangesResource(socket) def positions(self, address: str) -> "WalletPositionsSubscription": @@ -44,6 +46,28 @@ def perp_executions(self, address: str) -> "WalletPerpExecutionsSubscription": """ return self._perp_executions.for_wallet(address) + def spot_executions(self, address: str) -> "WalletSpotExecutionsSubscription": + """Get spot executions for a specific wallet address. + + Args: + address: The wallet address. + + Returns: + A subscription object for the wallet spot executions. + """ + return self._spot_executions.for_wallet(address) + + def balances(self, address: str) -> "WalletBalancesSubscription": + """Get balances for a specific wallet address. + + Args: + address: The wallet address. + + Returns: + A subscription object for the wallet balances. + """ + return self._balances.for_wallet(address) + def order_changes(self, address: str) -> "WalletOrderChangesSubscription": """Get order changes for a specific wallet address. @@ -204,3 +228,103 @@ def subscribe(self, batched: bool = False) -> None: def unsubscribe(self) -> None: """Unsubscribe from wallet open orders.""" self.socket.send_unsubscribe(channel=self.path) + + +class WalletSpotExecutionsResource(SubscribableParameterizedResource): + """Resource for accessing wallet spot executions.""" + + def __init__(self, socket: "ReyaSocket"): + """Initialize the wallet spot executions resource. + + Args: + socket: The WebSocket connection to use for this resource. + """ + super().__init__(socket, "/v2/wallet/{address}/spotExecutions") + + def for_wallet(self, address: str) -> "WalletSpotExecutionsSubscription": + """Create a subscription for a specific wallet's spot executions. + + Args: + address: The wallet address. + + Returns: + A subscription object for the wallet spot executions. + """ + return WalletSpotExecutionsSubscription(self.socket, address) + + +class WalletSpotExecutionsSubscription: + """Manages a subscription to spot executions for a specific wallet.""" + + def __init__(self, socket: "ReyaSocket", address: str): + """Initialize a wallet spot executions subscription. + + Args: + socket: The WebSocket connection to use for this subscription. + address: The wallet address. + """ + self.socket = socket + self.address = address + self.path = f"/v2/wallet/{address}/spotExecutions" + + def subscribe(self, batched: bool = False) -> None: + """Subscribe to wallet spot executions. + + Args: + batched: Whether to receive updates in batches. + """ + self.socket.send_subscribe(channel=self.path, batched=batched) + + def unsubscribe(self) -> None: + """Unsubscribe from wallet spot executions.""" + self.socket.send_unsubscribe(channel=self.path) + + +class WalletBalancesResource(SubscribableParameterizedResource): + """Resource for accessing wallet balances.""" + + def __init__(self, socket: "ReyaSocket"): + """Initialize the wallet balances resource. + + Args: + socket: The WebSocket connection to use for this resource. + """ + super().__init__(socket, "/v2/wallet/{address}/balances") + + def for_wallet(self, address: str) -> "WalletBalancesSubscription": + """Create a subscription for a specific wallet's balances. + + Args: + address: The wallet address. + + Returns: + A subscription object for the wallet balances. + """ + return WalletBalancesSubscription(self.socket, address) + + +class WalletBalancesSubscription: + """Manages a subscription to balances for a specific wallet.""" + + def __init__(self, socket: "ReyaSocket", address: str): + """Initialize a wallet balances subscription. + + Args: + socket: The WebSocket connection to use for this subscription. + address: The wallet address. + """ + self.socket = socket + self.address = address + self.path = f"/v2/wallet/{address}/accountBalances" + + def subscribe(self, batched: bool = False) -> None: + """Subscribe to wallet balances. + + Args: + batched: Whether to receive updates in batches. + """ + self.socket.send_subscribe(channel=self.path, batched=batched) + + def unsubscribe(self) -> None: + """Unsubscribe from wallet balances.""" + self.socket.send_unsubscribe(channel=self.path) diff --git a/sdk/reya_websocket/socket.py b/sdk/reya_websocket/socket.py index b7ba0abd..af306c03 100644 --- a/sdk/reya_websocket/socket.py +++ b/sdk/reya_websocket/socket.py @@ -1,18 +1,30 @@ -"""WebSocket client implementation for the Reya API v2.""" +"""WebSocket client implementation for the Reya API v2. -from typing import Any, Callable, Optional +This module provides a WebSocket client that follows the same patterns as the REST API: +- All messages are parsed into typed Pydantic models +- Callbacks receive typed payloads directly (no raw dict access) +- Parsing failures raise exceptions (fail-fast, like REST) +""" + +from typing import Callable, Optional, Union, cast import json import logging import ssl import threading -import websocket from pydantic import BaseModel, ValidationError +from websocket import WebSocket, WebSocketApp # type: ignore[attr-defined] # pylint: disable=no-name-in-module +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_perp_execution_update_payload import ( MarketPerpExecutionUpdatePayload, ) +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 from sdk.async_api.order_change_update_payload import OrderChangeUpdatePayload @@ -21,9 +33,14 @@ 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.subscribed_message_payload import SubscribedMessagePayload +from sdk.async_api.unsubscribed_message_payload import UnsubscribedMessagePayload from sdk.async_api.wallet_perp_execution_update_payload import ( WalletPerpExecutionUpdatePayload, ) +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 from sdk.reya_websocket.resources.prices import PricesResource @@ -33,61 +50,60 @@ logger = logging.getLogger("reya.websocket") -def as_json( - on_message: Optional[Callable[[Any, Any], None]], -) -> Callable[[Any, str], None]: - """Wrap a message handler to parse JSON messages. - - Args: - on_message: The original message handler. - - Returns: - A wrapped message handler that parses JSON messages before passing them to the original handler. - """ - - def wrapper(ws, message: str): - # Always log raw message for debugging - logger.debug(f"RAW WEBSOCKET MESSAGE: {message!r}") - if on_message is not None: - return on_message(ws, json.loads(message)) - return None - - return wrapper +# Type alias for all possible WebSocket message payloads +# Based on AsyncAPI spec: asyncapi-trading-v2.yaml +WebSocketMessage = Union[ + # Control messages + PingMessagePayload, + PongMessagePayload, + SubscribedMessagePayload, + UnsubscribedMessagePayload, + ErrorMessagePayload, + # Market channels + MarketsSummaryUpdatePayload, # /v2/markets/summary + MarketSummaryUpdatePayload, # /v2/market/{symbol}/summary + MarketPerpExecutionUpdatePayload, # /v2/market/{symbol}/perpExecutions + MarketSpotExecutionUpdatePayload, # /v2/market/{symbol}/spotExecutions + 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 + AccountBalanceUpdatePayload, # /v2/wallet/{address}/accountBalances + # Price channels + PricesUpdatePayload, # /v2/prices + PriceUpdatePayload, # /v2/prices/{symbol} +] class WebSocketDataError(Exception): - """Exception raised when WebSocket data cannot be parsed.""" + """Exception raised when WebSocket data cannot be parsed into a typed model.""" -class ReyaSocket(websocket.WebSocketApp): +class ReyaSocket(WebSocketApp): """WebSocket client for Reya API v2 with resource-based access and type safety.""" # Channel to payload type mapping for V2 + # Note: Parameterized channels (with {symbol} or {address}) are handled in _get_payload_type() + # This map is only for exact matches and control messages CHANNEL_PAYLOAD_MAP: dict[str, type[BaseModel]] = { - # Ping/Pong + # Control messages (matched by message type, not channel) "ping": PingMessagePayload, "pong": PongMessagePayload, - # Markets + # All markets summary (exact match) "/v2/markets/summary": MarketsSummaryUpdatePayload, - # Market-specific (regex patterns handled in _get_payload_type) - "/v2/market/": MarketSummaryUpdatePayload, # /v2/market/{symbol}/summary - "/v2/market/perpExecutions": MarketPerpExecutionUpdatePayload, # /v2/market/{symbol}/perpExecutions - # Wallet-specific (regex patterns handled in _get_payload_type) - "/v2/wallet/positions": PositionUpdatePayload, # /v2/wallet/{address}/positions - "/v2/wallet/orderChanges": OrderChangeUpdatePayload, # /v2/wallet/{address}/orderChanges - "/v2/wallet/perpExecutions": WalletPerpExecutionUpdatePayload, # /v2/wallet/{address}/perpExecutions - # Prices + # All prices (exact match) "/v2/prices": PricesUpdatePayload, - "/v2/prices/": PriceUpdatePayload, # /v2/prices/{symbol} } def __init__( self, url: Optional[str] = None, - on_open: Optional[Callable[[websocket.WebSocket], None]] = None, - on_message: Optional[Callable[[websocket.WebSocket, Any], None]] = None, - on_error: Optional[Callable[[websocket.WebSocket, Any], None]] = None, - on_close: Optional[Callable[[websocket.WebSocket, int, str], None]] = None, + on_open: Optional[Callable[[WebSocket], None]] = None, + on_message: Optional[Callable[[WebSocket, WebSocketMessage], None]] = None, + on_error: Optional[Callable[[WebSocket, Exception], None]] = None, + on_close: Optional[Callable[[WebSocket, int, str], None]] = None, config: Optional[WebSocketConfig] = None, **kwargs, ): @@ -96,10 +112,11 @@ def __init__( Args: url: The WebSocket server URL. If None, uses the URL from config. on_open: Callback for connection open events. - on_message: Callback for message events. + on_message: Callback for message events. Receives typed Pydantic models + directly (same pattern as REST API). on_error: Callback for error events. on_close: Callback for connection close events. - config: WebSocket configuration. If None, loads from sdk.reya_websocketenv file. + config: WebSocket configuration. If None, loads from env file. **kwargs: Additional keyword arguments for WebSocketApp. """ # Set up configuration @@ -114,11 +131,12 @@ def __init__( # Initialize thread attribute self._thread: Optional[threading.Thread] = None + # Store user callback for wrapping + self._user_on_message = on_message + # Default handlers if none provided if on_open is None: on_open = self._default_on_open - if on_message is None: - on_message = self._default_on_message if on_error is None: on_error = self._default_on_error if on_close is None: @@ -130,12 +148,36 @@ def __init__( super().__init__( url=url, on_open=on_open, - on_message=as_json(on_message), + on_message=self._wrap_message_handler(), on_error=on_error, on_close=on_close, **kwargs, ) + def _wrap_message_handler(self) -> Callable[[WebSocket, str], None]: + """Create a message handler that parses JSON into typed Pydantic models. + + Following REST API patterns: + - All messages are parsed into typed models + - Parsing failures raise WebSocketDataError + - Callbacks receive typed payloads directly + """ + + def wrapper(ws: WebSocket, message: str) -> None: + logger.debug(f"RAW WEBSOCKET MESSAGE: {message!r}") + raw = json.loads(message) + + # Parse into typed model (raises WebSocketDataError on failure) + typed_message = self._parse_message(raw) + + # Call user callback or default with typed message + if self._user_on_message is not None: + self._user_on_message(ws, typed_message) + else: + self._default_on_message(ws, typed_message) + + return wrapper + def _get_payload_type(self, channel: str) -> Optional[type[BaseModel]]: """Get the appropriate payload type for a channel. @@ -155,6 +197,10 @@ def _get_payload_type(self, channel: str) -> Optional[type[BaseModel]]: return MarketSummaryUpdatePayload elif channel.endswith("/perpExecutions"): return MarketPerpExecutionUpdatePayload + elif channel.endswith("/spotExecutions"): + return MarketSpotExecutionUpdatePayload + elif channel.endswith("/depth"): + return MarketDepthUpdatePayload elif "/v2/wallet/" in channel: if channel.endswith("/positions"): return PositionUpdatePayload @@ -162,43 +208,65 @@ def _get_payload_type(self, channel: str) -> Optional[type[BaseModel]]: return OrderChangeUpdatePayload elif channel.endswith("/perpExecutions"): return WalletPerpExecutionUpdatePayload - elif "/v2/prices/" in channel and not channel == "/v2/prices": + elif channel.endswith("/spotExecutions"): + return WalletSpotExecutionUpdatePayload + elif channel.endswith("/accountBalances"): + return AccountBalanceUpdatePayload + elif "/v2/prices/" in channel and channel != "/v2/prices": return PriceUpdatePayload return None - def _parse_message(self, message: dict) -> Optional[BaseModel]: - """Parse a WebSocket message into the appropriate Pydantic model. + def _parse_message(self, message: dict) -> WebSocketMessage: + """Parse a WebSocket message into the appropriate typed Pydantic model. + + Following REST API patterns, this method always returns a typed model + or raises an exception. No raw dict fallback. Args: message: The raw message dictionary. Returns: - Parsed Pydantic model or None if parsing fails. + Typed Pydantic model for the message. + + Raises: + WebSocketDataError: If the message cannot be parsed into a typed model. """ message_type = message.get("type") - if message_type in ["ping", "pong"]: - payload_type = self.CHANNEL_PAYLOAD_MAP.get(message_type) - if payload_type: - try: - return payload_type.model_validate(message) - except ValidationError as e: - logger.error(f"Failed to parse {message_type} message: {e}") - raise WebSocketDataError(f"Invalid {message_type} message format") - - elif message_type == "channel_data": - channel = message.get("channel") - if channel: + try: + if message_type == "ping": + return PingMessagePayload.model_validate(message) + + elif message_type == "pong": + return 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) + + elif message_type == "unsubscribed": + return UnsubscribedMessagePayload.model_validate(message) + + elif message_type == "error": + return ErrorMessagePayload.model_validate(message) + + elif message_type == "channel_data": + channel = message.get("channel", "") payload_type = self._get_payload_type(channel) - if payload_type: - try: - return payload_type.model_validate(message) - except ValidationError as e: - logger.error(f"Failed to parse channel_data for {channel}: {e}") - raise WebSocketDataError(f"Invalid data format for channel {channel}") + if payload_type is None: + raise WebSocketDataError(f"Unknown channel: {channel}") + return cast(WebSocketMessage, payload_type.model_validate(message)) - return None + else: + raise WebSocketDataError(f"Unknown message type: {message_type}") + + except ValidationError as e: + logger.error(f"Failed to parse {message_type} message: {e}") + raise WebSocketDataError(f"Invalid {message_type} message format: {e}") @property def market(self) -> MarketResource: @@ -289,55 +357,37 @@ def _default_on_open(self, _ws): # Send ping to confirm connection and trigger subscription workflow self.send(json.dumps({"type": "ping"})) - def _default_on_message(self, _ws, message): - """Default handler for message events with V2 support.""" - message_type = message.get("type") + def _default_on_message(self, _ws: WebSocket, message: WebSocketMessage) -> None: + """Default handler for message events. - # Try to parse message with Pydantic models for type safety - try: - parsed_message = self._parse_message(message) - if parsed_message: - logger.debug(f"Parsed message as {type(parsed_message).__name__}") - except WebSocketDataError as e: - logger.warning(f"Message parsing failed: {e}") - parsed_message = None - - if message_type == "connected": - logger.info("Connection established with server") + Args: + message: Typed Pydantic model for the WebSocket message. + """ + logger.debug(f"Received {type(message).__name__}") - elif message_type == "pong": + if isinstance(message, PongMessagePayload): logger.info("Connection confirmed via pong response") - elif message_type == "subscribed": - channel = message.get("channel", "unknown") - logger.info(f"Successfully subscribed to {channel}") - - elif message_type == "unsubscribed": - channel = message.get("channel", "unknown") - logger.info(f"Successfully unsubscribed from {channel}") + elif isinstance(message, SubscribedMessagePayload): + logger.info(f"Successfully subscribed to {message.channel}") - elif message_type == "channel_data": - channel = message.get("channel", "unknown") - timestamp = message.get("timestamp") + elif isinstance(message, UnsubscribedMessagePayload): + logger.info(f"Successfully unsubscribed from {message.channel}") - logger.debug(f"Received data from {channel} at {timestamp}") + elif isinstance(message, ErrorMessagePayload): + logger.error(f"Received error: {message.message}") - # Log structured data if parsing succeeded - if parsed_message and hasattr(parsed_message, "data"): - data_type = type(parsed_message.data) - if hasattr(data_type, "__origin__") and data_type.__origin__ is list: - logger.debug(f"Received {len(parsed_message.data)} items") - else: - logger.debug(f"Received {type(parsed_message.data).__name__} data") - - elif message_type == "error": - logger.error(f"Received error: {message.get('message', 'unknown')}") - - elif message_type == "ping": + elif isinstance(message, PingMessagePayload): logger.debug("Received ping from server") - else: - logger.debug(f"Received unknown message type: {message_type} - {message}") + elif hasattr(message, "data"): + # Channel data messages have a 'data' attribute + channel = getattr(message, "channel", "unknown") + data = message.data + if isinstance(data, list): + logger.debug(f"Received {len(data)} items from {channel}") + else: + logger.debug(f"Received {type(data).__name__} from {channel}") def _default_on_error(self, _ws, error): """Default handler for error events.""" diff --git a/specs b/specs index 8849b520..8903d3b2 160000 --- a/specs +++ b/specs @@ -1 +1 @@ -Subproject commit 8849b52022077b6bae38dfc09bb26875aa64f0f2 +Subproject commit 8903d3b2c42b5ddecb3cd6eef9577adb982bf75b diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..f76b018c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,6 @@ +""" +Reya Trading SDK Tests + +Integration tests for the Reya Python SDK. +Test helpers are located in the helpers/ subdirectory. +""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..a945f426 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,610 @@ +""" +Pytest fixtures for Reya Python SDK integration tests. + +Uses pytest-asyncio's loop_scope feature (v0.24+) to share a single event loop +across all tests in a session, enabling session-scoped async fixtures. +""" + +import asyncio +import os +from decimal import Decimal + +import pytest +import pytest_asyncio +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 SpotTestConfig + +# Time delay between tests +TEST_DELAY_SECONDS = 0.1 + +# Minimum balance requirements for SPOT tests +MIN_ETH_BALANCE = 0.05 +MIN_RUSD_BALANCE = 15.0 + + +@pytest_asyncio.fixture(loop_scope="session", scope="function", autouse=True) +async def rate_limit_delay(): + """ + Add a small delay after each test to avoid WAF rate limiting. + + The staging environment uses AWS WAF which can block requests if too many + come from the same IP in a short time window. This delay helps prevent + 403 Forbidden errors during test runs. + """ + yield + await asyncio.sleep(TEST_DELAY_SECONDS) + + +# ============================================================================ +# Session-Scoped Fixtures (Single connection for entire test suite) +# ============================================================================ +# Using loop_scope="session" ensures all fixtures share the same event loop + + +@pytest_asyncio.fixture(loop_scope="session", scope="session") +async def reya_tester_session(): + """ + Session-scoped ReyaTester - ONE connection for the entire test suite. + + This dramatically reduces test time by maintaining a single WebSocket and + API connection throughout all tests. + """ + load_dotenv() + + tester = ReyaTester() + + if not tester.owner_wallet_address or not tester.account_id: + pytest.skip("Missing required wallet address or account ID for tests") + + logger.info("=" * 60) + logger.info("🚀 SESSION START: Initializing single WebSocket connection") + logger.info(f" Wallet: {tester.owner_wallet_address}") + logger.info(f" Account: {tester.account_id}") + logger.info("=" * 60) + + # setup() calls client.start() internally, no need to call it separately + await tester.setup() + + yield tester + + # Cleanup at end of entire test session + logger.info("=" * 60) + logger.info("🧹 SESSION END: Closing connections") + logger.info("=" * 60) + 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("✅ Session cleanup completed") + except (OSError, RuntimeError, asyncio.CancelledError) as e: + logger.warning(f"Error during session cleanup: {e}") + + +@pytest_asyncio.fixture(loop_scope="session", scope="function") +async def reya_tester(reya_tester_session): # pylint: disable=redefined-outer-name + """ + Function-scoped wrapper that cleans state between tests. + + Reuses the session-scoped connection but ensures clean state for each test. + """ + # Clean up any leftover positions and orders from previous test + await reya_tester_session.positions.close_all(fail_if_none=False) + await reya_tester_session.orders.close_all(fail_if_none=False) + + # Clear ALL WebSocket tracking state for fresh test + reya_tester_session.ws.clear() + + yield reya_tester_session + + # Clean up positions and orders after test (connection stays open) + await reya_tester_session.positions.close_all(fail_if_none=False) + await reya_tester_session.orders.close_all(fail_if_none=False) + + +# ============================================================================ +# Multi-Account Session Fixtures (Maker/Taker) +# ============================================================================ + + +@pytest_asyncio.fixture(loop_scope="session", scope="session") +async def maker_tester_session(): + """ + Session-scoped maker account - ONE connection for entire test suite. + + Uses SPOT_ACCOUNT_ID_1, SPOT_PRIVATE_KEY_1, SPOT_WALLET_ADDRESS_1 as the maker. + """ + load_dotenv() + + # Maker uses Spot Account 1 + tester = ReyaTester(spot_account_number=1) + + if not tester.owner_wallet_address or not tester.account_id: + pytest.skip( + "Missing Spot Account 1 configuration (SPOT_ACCOUNT_ID_1, SPOT_PRIVATE_KEY_1, SPOT_WALLET_ADDRESS_1) for spot tests" + ) + + logger.info(f"🔧 SESSION: Maker account initialized: {tester.account_id}") + + # setup() calls client.start() internally + await tester.setup() + + yield tester + + # Cleanup + try: + if tester.websocket: + tester.websocket.close() + preserve_orders = os.getenv("SPOT_PRESERVE_ACCOUNT1_ORDERS", "").lower() == "true" + if not preserve_orders: + await tester.orders.close_all(fail_if_none=False) + else: + logger.info("⚠️ SPOT_PRESERVE_ACCOUNT1_ORDERS=true: Skipping session cleanup for maker account") + await tester.client.close() + logger.info("✅ Maker session cleanup completed") + except (OSError, RuntimeError, asyncio.CancelledError) as e: + logger.warning(f"Error during maker cleanup: {e}") + + +@pytest_asyncio.fixture(loop_scope="session", scope="session") +async def taker_tester_session(): + """ + Session-scoped taker account - ONE connection for entire test suite. + + Uses SPOT_ACCOUNT_ID_2, SPOT_PRIVATE_KEY_2, SPOT_WALLET_ADDRESS_2 as the taker. + """ + load_dotenv() + + # Taker uses Spot Account 2 + tester = ReyaTester(spot_account_number=2) + + if not tester.owner_wallet_address or not tester.account_id: + pytest.skip( + "Missing Spot Account 2 configuration (SPOT_ACCOUNT_ID_2, SPOT_PRIVATE_KEY_2, SPOT_WALLET_ADDRESS_2) for spot tests" + ) + + logger.info(f"🔧 SESSION: Taker account initialized: {tester.account_id}") + + # setup() calls client.start() internally + await tester.setup() + + yield tester + + # Cleanup + try: + if tester.websocket: + tester.websocket.close() + await tester.orders.close_all(fail_if_none=False) + await tester.client.close() + logger.info("✅ Taker session cleanup completed") + except (OSError, RuntimeError, asyncio.CancelledError) as e: + logger.warning(f"Error during taker cleanup: {e}") + + +@pytest_asyncio.fixture(loop_scope="session", scope="function") +async def maker_tester(maker_tester_session): # pylint: disable=redefined-outer-name + """ + Function-scoped wrapper for maker that cleans state between tests. + + Set SPOT_PRESERVE_ACCOUNT1_ORDERS=true to skip order cleanup for SPOT_ACCOUNT_ID_1. + This is useful when testing with external liquidity from a depth script. + """ + preserve_orders = os.getenv("SPOT_PRESERVE_ACCOUNT1_ORDERS", "").lower() == "true" + + if not preserve_orders: + await maker_tester_session.orders.close_all(fail_if_none=False) + else: + logger.info("⚠️ SPOT_PRESERVE_ACCOUNT1_ORDERS=true: Skipping order cleanup for maker account") + maker_tester_session.ws.clear() + + yield maker_tester_session + + if not preserve_orders: + await maker_tester_session.orders.close_all(fail_if_none=False) + + +@pytest_asyncio.fixture(loop_scope="session", scope="function") +async def spot_tester(maker_tester_session): # pylint: disable=redefined-outer-name + """ + Function-scoped wrapper for single-account spot tests. + Uses SPOT account 1 (same as maker_tester). + + Set SPOT_PRESERVE_ACCOUNT1_ORDERS=true to skip order cleanup for SPOT_ACCOUNT_ID_1. + This is useful when testing with external liquidity from a depth script. + """ + preserve_orders = os.getenv("SPOT_PRESERVE_ACCOUNT1_ORDERS", "").lower() == "true" + + if not preserve_orders: + await maker_tester_session.orders.close_all(fail_if_none=False) + else: + logger.info("⚠️ SPOT_PRESERVE_ACCOUNT1_ORDERS=true: Skipping order cleanup for spot_tester") + maker_tester_session.ws.clear() + + yield maker_tester_session + + if not preserve_orders: + await maker_tester_session.orders.close_all(fail_if_none=False) + + +@pytest_asyncio.fixture(loop_scope="session", scope="function") +async def taker_tester(taker_tester_session): # pylint: disable=redefined-outer-name + """ + Function-scoped wrapper for taker that cleans state between tests. + """ + await taker_tester_session.orders.close_all(fail_if_none=False) + taker_tester_session.ws.clear() + + yield taker_tester_session + + await taker_tester_session.orders.close_all(fail_if_none=False) + + +# ============================================================================ +# SPOT Test Configuration Fixture +# ============================================================================ + + +@pytest_asyncio.fixture(loop_scope="session", scope="session") +async def spot_config(maker_tester_session): # pylint: disable=redefined-outer-name + """ + Session-scoped fixture that provides centralized SPOT test configuration. + + Fetches the current ETH oracle price dynamically and provides a + SpotTestConfig object with all test parameters. + + Usage in tests: + async def test_something(spot_config, maker_tester): + maker_price = spot_config.price(0.99) # 99% of oracle + qty = spot_config.min_qty + symbol = spot_config.symbol + """ + # Use ETHRUSD spot market for oracle price + oracle_symbol = "ETHRUSD" + + try: + price_str = await maker_tester_session.data.current_price(oracle_symbol) + oracle_price = float(price_str) + logger.info(f"📊 Fetched ETH oracle price: ${oracle_price:.2f}") + except (OSError, RuntimeError, ValueError) as e: + logger.warning(f"Failed to fetch oracle price for {oracle_symbol}: {e}") + oracle_price = 3000.0 + logger.warning(f"Using fallback oracle price: ${oracle_price:.2f}") + + return SpotTestConfig(symbol="WETHRUSD", min_qty="0.001", oracle_price=oracle_price) + + +# ============================================================================ +# SPOT Balance Guard Fixture +# ============================================================================ + + +async def _get_account_balance(tester: ReyaTester, asset: str) -> Decimal: + """Get balance for a specific asset from a tester's account.""" + balance = await tester.data.balance(asset) + if balance and balance.real_balance: + return Decimal(balance.real_balance) + return Decimal("0") + + +async def _execute_spot_transfer( + sender: ReyaTester, + receiver: ReyaTester, + symbol: str, + qty: str, + price: str, +) -> bool: + """ + Transfer spot assets between accounts via order matching. + + Sender places GTC sell, receiver places IOC buy at same price. + Returns True if transfer succeeded. + """ + # Step 1: Sender places GTC sell order + sell_params = LimitOrderParameters( + symbol=symbol, + is_buy=False, + limit_px=price, + qty=qty, + time_in_force=TimeInForce.GTC, + ) + + sell_response = await sender.client.create_limit_order(sell_params) + sell_order_id = sell_response.order_id + + if not sell_order_id: + logger.error("Failed to create sell order for transfer") + return False + + await asyncio.sleep(0.3) + + # Step 2: Receiver places IOC buy order to match + buy_params = LimitOrderParameters( + symbol=symbol, + is_buy=True, + limit_px=price, + qty=qty, + time_in_force=TimeInForce.IOC, + ) + + await receiver.client.create_limit_order(buy_params) + + # Step 3: Wait for settlement and cancel any remaining sell order + await asyncio.sleep(1.0) + + try: + open_orders = await sender.client.get_open_orders() + for order in open_orders: + if hasattr(order, "order_id") and order.order_id == sell_order_id: + await sender.client.cancel_order( + order_id=sell_order_id, + symbol=symbol, + account_id=sender.account_id, + ) + break + except (OSError, RuntimeError) as e: # nosec B110 + logger.debug(f"Order cancel failed (may have been fully filled): {e}") + + return True + + +@pytest_asyncio.fixture(loop_scope="session", scope="session") +async def spot_balance_guard( + maker_tester_session, taker_tester_session, spot_config +): # pylint: disable=redefined-outer-name + """ + Session-scoped fixture that checks balances before SPOT tests and restores them after. + + NOTE: This fixture is NOT autouse - it must be explicitly requested by spot tests. + + At session start: + - Checks both accounts have minimum required balances (0.05 ETH, 50 RUSD each) + - Stores initial balances for restoration + - Skips all SPOT tests if balances are insufficient + + At session end: + - Calculates balance differences from initial state + - Executes transfers to restore initial balances + """ + logger.info("=" * 60) + logger.info("💰 SPOT BALANCE GUARD: Checking account balances") + logger.info("=" * 60) + + # Get initial balances for both accounts + maker_eth = await _get_account_balance(maker_tester_session, "ETH") + maker_rusd = await _get_account_balance(maker_tester_session, "RUSD") + taker_eth = await _get_account_balance(taker_tester_session, "ETH") + taker_rusd = await _get_account_balance(taker_tester_session, "RUSD") + + logger.info(f"📊 Account 1 (Maker): {maker_eth} ETH, {maker_rusd} RUSD") + logger.info(f"📊 Account 2 (Taker): {taker_eth} ETH, {taker_rusd} RUSD") + + # Store initial balances for restoration + initial_balances = { + "maker_eth": maker_eth, + "maker_rusd": maker_rusd, + "taker_eth": taker_eth, + "taker_rusd": taker_rusd, + } + + # Check minimum requirements + min_eth = Decimal(str(MIN_ETH_BALANCE)) + min_rusd = Decimal(str(MIN_RUSD_BALANCE)) + + insufficient = [] + if maker_eth < min_eth: + insufficient.append(f"Account 1 ETH: {maker_eth} < {min_eth}") + if maker_rusd < min_rusd: + insufficient.append(f"Account 1 RUSD: {maker_rusd} < {min_rusd}") + if taker_eth < min_eth: + insufficient.append(f"Account 2 ETH: {taker_eth} < {min_eth}") + if taker_rusd < min_rusd: + insufficient.append(f"Account 2 RUSD: {taker_rusd} < {min_rusd}") + + if insufficient: + logger.error("❌ INSUFFICIENT BALANCES FOR SPOT TESTS:") + for msg in insufficient: + logger.error(f" - {msg}") + logger.error(f" Required minimums: {MIN_ETH_BALANCE} ETH, {MIN_RUSD_BALANCE} RUSD per account") + pytest.skip(f"Insufficient balances for SPOT tests: {', '.join(insufficient)}") + + logger.info("✅ Balance check passed - proceeding with SPOT tests") + logger.info("=" * 60) + + # Run all SPOT tests + yield initial_balances + + # ======================================================================== + # BALANCE RESTORATION (runs after all SPOT tests complete) + # ======================================================================== + # This restoration logic handles both scenarios: + # 1. Tests traded only between maker and taker accounts + # 2. Tests traded with external liquidity (non-empty order book) + # + # Strategy: Track each account independently and restore via maker↔taker + # transfers. External liquidity trades will show as imbalances that we + # correct by transferring between our accounts. + # ======================================================================== + logger.info("=" * 60) + logger.info("💰 SPOT BALANCE GUARD: Restoring account balances") + logger.info("=" * 60) + + # Get final balances for both accounts + final_maker_eth = await _get_account_balance(maker_tester_session, "ETH") + final_maker_rusd = await _get_account_balance(maker_tester_session, "RUSD") + final_taker_eth = await _get_account_balance(taker_tester_session, "ETH") + final_taker_rusd = await _get_account_balance(taker_tester_session, "RUSD") + + logger.info(f"📊 Final Account 1 (Maker): {final_maker_eth} ETH, {final_maker_rusd} RUSD") + logger.info(f"📊 Final Account 2 (Taker): {final_taker_eth} ETH, {final_taker_rusd} RUSD") + + # Calculate changes for EACH account independently + maker_eth_change = final_maker_eth - initial_balances["maker_eth"] + maker_rusd_change = final_maker_rusd - initial_balances["maker_rusd"] + taker_eth_change = final_taker_eth - initial_balances["taker_eth"] + taker_rusd_change = final_taker_rusd - initial_balances["taker_rusd"] + + logger.info(f"📈 Maker changes: ETH {maker_eth_change:+}, RUSD {maker_rusd_change:+}") + logger.info(f"📈 Taker changes: ETH {taker_eth_change:+}, RUSD {taker_rusd_change:+}") + + symbol = "WETHRUSD" + oracle_price = Decimal(str(spot_config.oracle_price)) + min_price = oracle_price * Decimal("0.95") + max_price = oracle_price * Decimal("1.05") + + # Determine restoration needs + # Net ETH change across both accounts (non-zero means external trades occurred) + net_eth_change = maker_eth_change + taker_eth_change + if abs(net_eth_change) >= Decimal("0.001"): + logger.info(f"📊 Net ETH change (external trades): {net_eth_change:+}") + + min_qty = Decimal("0.001") + + # Calculate what each account needs to reach initial balance + maker_eth_needed = initial_balances["maker_eth"] - final_maker_eth # positive = needs more ETH + taker_eth_needed = initial_balances["taker_eth"] - final_taker_eth # positive = needs more ETH + + logger.info(f"📊 Maker needs: {maker_eth_needed:+} ETH, Taker needs: {taker_eth_needed:+} ETH") + + # Get current order book to determine restoration strategy + depth = await taker_tester_session.data.market_depth(symbol) + has_external_bids = depth.bids and len(depth.bids) > 0 + has_external_asks = depth.asks and len(depth.asks) > 0 + has_external_liquidity = has_external_bids or has_external_asks + + logger.info(f"📊 Order book: bids={has_external_bids}, asks={has_external_asks}") + + # ======================================================================== + # RESTORATION STRATEGY: + # - If NO external liquidity: Use internal transfers (maker ↔ taker) + # - If external liquidity exists: Each account trades with external liquidity + # (internal transfers won't work because IOC orders match external first) + # ======================================================================== + + if not has_external_liquidity: + # EMPTY ORDER BOOK: Use internal transfers between maker and taker + logger.info("📋 Empty order book - using internal transfers") + + # Calculate restoration price based on RUSD changes + total_eth_moved = abs(maker_eth_change) + abs(taker_eth_change) + total_rusd_moved = abs(maker_rusd_change) + abs(taker_rusd_change) + + if total_eth_moved > Decimal("0") and total_rusd_moved > Decimal("0.01"): + effective_price = total_rusd_moved / total_eth_moved + else: + effective_price = oracle_price + + restoration_price = max(min_price, min(max_price, effective_price)) + restoration_price = restoration_price.quantize(Decimal("0.01")) + logger.info(f"💱 Restoration price: ${restoration_price}") + + if abs(maker_eth_needed) >= min_qty: + if maker_eth_needed > 0: + qty = str(maker_eth_needed.quantize(min_qty)) + logger.info(f"🔄 Internal transfer: {qty} ETH from Taker → Maker @ ${restoration_price}") + await _execute_spot_transfer( + sender=taker_tester_session, + receiver=maker_tester_session, + symbol=symbol, + qty=qty, + price=str(restoration_price), + ) + else: + qty = str((-maker_eth_needed).quantize(min_qty)) + logger.info(f"🔄 Internal transfer: {qty} ETH from Maker → Taker @ ${restoration_price}") + await _execute_spot_transfer( + sender=maker_tester_session, + receiver=taker_tester_session, + symbol=symbol, + qty=qty, + price=str(restoration_price), + ) + await asyncio.sleep(1.0) + + else: + # NON-EMPTY ORDER BOOK: Each account trades with external liquidity + logger.info("📋 External liquidity present - each account trades with external") + + # Helper function to restore an account's ETH balance via external liquidity + async def restore_account_eth(tester: ReyaTester, eth_needed: Decimal, account_name: str): + if abs(eth_needed) < min_qty: + return + + if eth_needed > 0: + # Account needs more ETH - buy from external asks + if not has_external_asks: + logger.warning(f"⚠️ {account_name} needs {eth_needed} ETH but no external asks available") + return + + qty = str(eth_needed.quantize(min_qty)) + best_ask = Decimal(str(depth.asks[0].px)) + buy_price = min(max_price, best_ask * Decimal("1.001")).quantize(Decimal("0.01")) + logger.info(f"🔄 {account_name}: Buying {qty} ETH @ ${buy_price} from external asks") + + try: + buy_params = LimitOrderParameters( + symbol=symbol, + is_buy=True, + limit_px=str(buy_price), + qty=qty, + time_in_force=TimeInForce.IOC, + ) + await tester.client.create_limit_order(buy_params) + await asyncio.sleep(0.5) + except (ApiException, OSError, RuntimeError) as e: + logger.warning(f"⚠️ {account_name}: Failed to buy ETH: {e}") + + else: + # Account has excess ETH - sell to external bids + if not has_external_bids: + logger.warning(f"⚠️ {account_name} has excess {-eth_needed} ETH but no external bids available") + return + + qty = str((-eth_needed).quantize(min_qty)) + best_bid = Decimal(str(depth.bids[0].px)) + sell_price = max(min_price, best_bid * Decimal("0.999")).quantize(Decimal("0.01")) + logger.info(f"🔄 {account_name}: Selling {qty} ETH @ ${sell_price} to external bids") + + try: + sell_params = LimitOrderParameters( + symbol=symbol, + is_buy=False, + limit_px=str(sell_price), + qty=qty, + time_in_force=TimeInForce.IOC, + ) + await tester.client.create_limit_order(sell_params) + await asyncio.sleep(0.5) + except (ApiException, OSError, RuntimeError) as e: + logger.warning(f"⚠️ {account_name}: Failed to sell ETH: {e}") + + # Restore both accounts + await restore_account_eth(maker_tester_session, maker_eth_needed, "Maker") + await restore_account_eth(taker_tester_session, taker_eth_needed, "Taker") + await asyncio.sleep(1.0) + + # Log final restored balances + restored_maker_eth = await _get_account_balance(maker_tester_session, "ETH") + restored_maker_rusd = await _get_account_balance(maker_tester_session, "RUSD") + restored_taker_eth = await _get_account_balance(taker_tester_session, "ETH") + restored_taker_rusd = await _get_account_balance(taker_tester_session, "RUSD") + + logger.info(f"✅ Final Account 1 (Maker): {restored_maker_eth} ETH, {restored_maker_rusd} RUSD") + logger.info(f"✅ Final Account 2 (Taker): {restored_taker_eth} ETH, {restored_taker_rusd} RUSD") + + # Log how close we got to initial balances for both accounts + maker_eth_diff = restored_maker_eth - initial_balances["maker_eth"] + maker_rusd_diff = restored_maker_rusd - initial_balances["maker_rusd"] + taker_eth_diff = restored_taker_eth - initial_balances["taker_eth"] + taker_rusd_diff = restored_taker_rusd - initial_balances["taker_rusd"] + + logger.info(f"📊 Maker remaining diff from initial: ETH {maker_eth_diff:+}, RUSD {maker_rusd_diff:+}") + logger.info(f"📊 Taker remaining diff from initial: ETH {taker_eth_diff:+}, RUSD {taker_rusd_diff:+}") + + logger.info("=" * 60) diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py new file mode 100644 index 00000000..fb8ca79d --- /dev/null +++ b/tests/helpers/__init__.py @@ -0,0 +1,22 @@ +"""Test helpers for Reya Python SDK integration tests.""" + +from .builders import OrderBuilder +from .liquidity_detector import ( + LiquidityDetector, + LiquidityInfo, + OrderBookState, + log_order_book_state, +) +from .reya_tester import ReyaTester, limit_order_params_to_order, logger, trigger_order_params_to_order + +__all__ = [ + "ReyaTester", + "OrderBuilder", + "limit_order_params_to_order", + "trigger_order_params_to_order", + "logger", + "LiquidityDetector", + "LiquidityInfo", + "OrderBookState", + "log_order_book_state", +] diff --git a/tests/helpers/builders/__init__.py b/tests/helpers/builders/__init__.py new file mode 100644 index 00000000..74ad0867 --- /dev/null +++ b/tests/helpers/builders/__init__.py @@ -0,0 +1,5 @@ +"""Order builders for creating test orders with fluent API.""" + +from .order_builder import OrderBuilder + +__all__ = ["OrderBuilder"] diff --git a/tests/helpers/builders/order_builder.py b/tests/helpers/builders/order_builder.py new file mode 100644 index 00000000..fd63e5d8 --- /dev/null +++ b/tests/helpers/builders/order_builder.py @@ -0,0 +1,299 @@ +""" +Fluent order builder for creating test orders. + +This module provides a builder pattern for constructing order parameters, +inspired by the Rust OrderBuilder in reya-chain/crates/matching-engine/tests/helpers.rs. + +Example usage: + # Create a simple GTC buy order with SpotTestConfig + params = ( + OrderBuilder(spot_config) + .buy() + .at_price(0.99) # 99% of oracle price + .build() + ) + + # Create an IOC sell order with explicit values + params = ( + OrderBuilder() + .symbol("ETHRUSDPERP") + .sell() + .qty("0.05") + .price("3950.0") + .ioc() + .reduce_only() + .build() + ) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from dataclasses import dataclass, field + +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api.models import LimitOrderParameters, TriggerOrderParameters + +if TYPE_CHECKING: + from tests.test_spot.spot_config import SpotTestConfig + + +@dataclass +class OrderBuilder: + """ + Fluent builder for creating LimitOrderParameters. + + Provides a chainable API for constructing order parameters with sensible defaults. + All setter methods return self to enable method chaining. + + Can optionally accept a SpotTestConfig for convenient price/qty/symbol defaults. + + Note: LimitOrderParameters only contains order-specific fields. + Account/exchange IDs are handled by the SDK client. + """ + + _config: SpotTestConfig | None = None + _symbol: str = "ETHRUSD" + _is_buy: bool = True + _qty: str = "0.01" + _limit_px: str = "4000.0" + _time_in_force: TimeInForce = field(default_factory=lambda: TimeInForce.GTC) + _reduce_only: bool | None = None + _expires_after: int | None = None + _client_order_id: int | None = None + + def __post_init__(self): + """Apply config defaults if provided.""" + if self._config is not None: + self._symbol = self._config.symbol + self._qty = self._config.min_qty + + @classmethod + def from_config(cls, config: SpotTestConfig) -> OrderBuilder: + """Create an OrderBuilder pre-configured with SpotTestConfig defaults.""" + builder = cls() + builder._config = config + builder._symbol = config.symbol + builder._qty = config.min_qty + return builder + + def symbol(self, symbol: str) -> OrderBuilder: + """Set the trading symbol (e.g., 'ETHRUSD', 'ETHRUSDPERP').""" + self._symbol = symbol + return self + + def spot(self, base: str = "ETH") -> OrderBuilder: + """Set symbol to a spot market (e.g., 'ETHRUSD').""" + self._symbol = f"{base}RUSD" + return self + + def perp(self, base: str = "ETH") -> OrderBuilder: + """Set symbol to a perp market (e.g., 'ETHRUSDPERP').""" + self._symbol = f"{base}RUSDPERP" + return self + + def buy(self) -> OrderBuilder: + """Set order side to BUY.""" + self._is_buy = True + return self + + def sell(self) -> OrderBuilder: + """Set order side to SELL.""" + self._is_buy = False + return self + + def side(self, is_buy: bool) -> OrderBuilder: + """Set order side explicitly.""" + self._is_buy = is_buy + return self + + def qty(self, qty: str) -> OrderBuilder: + """Set the order quantity.""" + self._qty = qty + return self + + def price(self, price: str) -> OrderBuilder: + """Set the limit price.""" + self._limit_px = price + return self + + def limit_px(self, price: str) -> OrderBuilder: + """Alias for price() - set the limit price.""" + self._limit_px = price + return self + + def at_price(self, multiplier: float) -> OrderBuilder: + """ + Set price as a multiplier of the oracle price from config. + + Requires OrderBuilder to be created with a SpotTestConfig. + + Args: + multiplier: Price multiplier (e.g., 0.99 for 99% of oracle) + + Returns: + self for method chaining + + Raises: + ValueError: If no config was provided to the builder + """ + if self._config is None: + raise ValueError("at_price() requires OrderBuilder to be created with a SpotTestConfig") + self._limit_px = str(self._config.price(multiplier)) + return self + + def gtc(self) -> OrderBuilder: + """Set time-in-force to Good-Till-Cancelled.""" + self._time_in_force = TimeInForce.GTC + return self + + def ioc(self) -> OrderBuilder: + """Set time-in-force to Immediate-Or-Cancel.""" + self._time_in_force = TimeInForce.IOC + return self + + def time_in_force(self, tif: TimeInForce) -> OrderBuilder: + """Set time-in-force explicitly.""" + self._time_in_force = tif + return self + + def reduce_only(self, value: bool = True) -> OrderBuilder: + """Set reduce_only flag (only valid for IOC orders).""" + self._reduce_only = value + return self + + def expires_after(self, timestamp_ms: int) -> OrderBuilder: + """Set expiration timestamp in milliseconds (IOC orders only).""" + self._expires_after = timestamp_ms + return self + + def client_order_id(self, client_order_id: int) -> OrderBuilder: + """Set a client-provided order ID for tracking.""" + self._client_order_id = client_order_id + return self + + def build(self) -> LimitOrderParameters: + """ + Build and return the LimitOrderParameters. + + Returns: + LimitOrderParameters configured with all set values. + """ + return LimitOrderParameters( + symbol=self._symbol, + is_buy=self._is_buy, + qty=self._qty, + limit_px=self._limit_px, + time_in_force=self._time_in_force, + reduce_only=self._reduce_only, + expires_after=self._expires_after, + client_order_id=self._client_order_id, + ) + + def copy(self) -> OrderBuilder: + """Create a copy of this builder with the same settings.""" + builder = OrderBuilder() + # Use object.__setattr__ to set dataclass fields directly + # This avoids protected-access warnings while still copying internal state + for field_name in [ + "_config", + "_symbol", + "_is_buy", + "_qty", + "_limit_px", + "_time_in_force", + "_reduce_only", + "_expires_after", + "_client_order_id", + ]: + setattr(builder, field_name, getattr(self, field_name)) + return builder + + +@dataclass +class TriggerOrderBuilder: + """ + Fluent builder for creating TriggerOrderParameters (TP/SL orders). + + Example usage: + # Create a take-profit order + params = ( + TriggerOrderBuilder() + .symbol("ETHRUSDPERP") + .take_profit() + .trigger_price("4200.0") + .buy() + .build() + ) + """ + + _symbol: str = "ETHRUSDPERP" + _is_buy: bool = True + _trigger_px: str = "4000.0" + _trigger_type: OrderType = field(default_factory=lambda: OrderType.TP) + + def symbol(self, symbol: str) -> TriggerOrderBuilder: + """Set the trading symbol.""" + self._symbol = symbol + return self + + def perp(self, base: str = "ETH") -> TriggerOrderBuilder: + """Set symbol to a perp market.""" + self._symbol = f"{base}RUSDPERP" + return self + + def buy(self) -> TriggerOrderBuilder: + """Set order side to BUY (close short position).""" + self._is_buy = True + return self + + def sell(self) -> TriggerOrderBuilder: + """Set order side to SELL (close long position).""" + self._is_buy = False + return self + + def trigger_price(self, price: str) -> TriggerOrderBuilder: + """Set the trigger price.""" + self._trigger_px = price + return self + + def trigger_px(self, price: str) -> TriggerOrderBuilder: + """Alias for trigger_price().""" + self._trigger_px = price + return self + + def take_profit(self) -> TriggerOrderBuilder: + """Set trigger type to Take Profit.""" + self._trigger_type = OrderType.TP + return self + + def stop_loss(self) -> TriggerOrderBuilder: + """Set trigger type to Stop Loss.""" + self._trigger_type = OrderType.SL + return self + + def tp(self) -> TriggerOrderBuilder: + """Alias for take_profit().""" + return self.take_profit() + + def sl(self) -> TriggerOrderBuilder: + """Alias for stop_loss().""" + return self.stop_loss() + + def build(self) -> TriggerOrderParameters: + """Build and return the TriggerOrderParameters.""" + return TriggerOrderParameters( + symbol=self._symbol, + is_buy=self._is_buy, + trigger_px=self._trigger_px, + trigger_type=self._trigger_type, + ) + + def copy(self) -> TriggerOrderBuilder: + """Create a copy of this builder.""" + builder = TriggerOrderBuilder() + for field_name in ["_symbol", "_is_buy", "_trigger_px", "_trigger_type"]: + setattr(builder, field_name, getattr(self, field_name)) + return builder diff --git a/tests/helpers/liquidity_detector.py b/tests/helpers/liquidity_detector.py new file mode 100644 index 00000000..bdb60968 --- /dev/null +++ b/tests/helpers/liquidity_detector.py @@ -0,0 +1,306 @@ +""" +Liquidity detection utilities for smart SPOT test execution. + +This module provides tools to detect and analyze order book liquidity, +enabling tests to automatically adapt to both empty and non-empty order books. +""" + +from typing import TYPE_CHECKING, Optional + +import logging +from dataclasses import dataclass, field +from decimal import Decimal + +if TYPE_CHECKING: + from sdk.open_api.models.depth import Depth + from sdk.open_api.models.level import Level + from tests.helpers.reya_tester.data import DataOperations + +logger = logging.getLogger("reya.integration_tests") + + +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 +SAFE_NO_MATCH_SELL_PRICE = Decimal("10000000") # $10M - far above any realistic ETH price + + +@dataclass +class LiquidityInfo: + """Liquidity information for one side of the order book.""" + + has_liquidity: bool + best_price: Optional[Decimal] + total_qty: Decimal + levels: list["Level"] = field(default_factory=list) + within_circuit_breaker: bool = False + + @property + def is_usable(self) -> bool: + """True if liquidity exists and is within circuit breaker range.""" + return self.has_liquidity and self.within_circuit_breaker + + +@dataclass +class OrderBookState: + """Complete order book state for a symbol.""" + + symbol: str + oracle_price: Decimal + bids: LiquidityInfo + asks: LiquidityInfo + + @property + def has_any_liquidity(self) -> bool: + """True if any liquidity exists on either side.""" + return self.bids.has_liquidity or self.asks.has_liquidity + + @property + def has_usable_bid_liquidity(self) -> bool: + """True if usable bid liquidity exists (for sell orders).""" + return self.bids.is_usable + + @property + def has_usable_ask_liquidity(self) -> bool: + """True if usable ask liquidity exists (for buy orders).""" + return self.asks.is_usable + + @property + def circuit_breaker_floor(self) -> Decimal: + """Minimum allowed price (oracle - 5%).""" + return (self.oracle_price * (1 - CIRCUIT_BREAKER_PCT)).quantize(Decimal("0.01")) + + @property + def circuit_breaker_ceiling(self) -> Decimal: + """Maximum allowed price (oracle + 5%).""" + return (self.oracle_price * (1 + CIRCUIT_BREAKER_PCT)).quantize(Decimal("0.01")) + + +class LiquidityDetector: + """Detects and analyzes order book liquidity for smart test execution.""" + + def __init__(self, oracle_price: float): + """ + Initialize the liquidity detector. + + Args: + oracle_price: Current oracle price for the symbol. + """ + self._oracle_price = Decimal(str(oracle_price)) + + async def get_order_book_state(self, data_ops: "DataOperations", symbol: str) -> OrderBookState: + """ + Fetch and analyze current order book state. + + Args: + data_ops: DataOperations instance for API calls. + symbol: Trading symbol to query. + + Returns: + OrderBookState with analyzed liquidity information. + """ + depth: Optional["Depth"] = await data_ops.market_depth(symbol) + + bids = self._analyze_side( + levels=depth.bids if depth else [], + _is_bid=True, + ) + asks = self._analyze_side( + levels=depth.asks if depth else [], + _is_bid=False, + ) + + return OrderBookState( + symbol=symbol, + oracle_price=self._oracle_price, + bids=bids, + asks=asks, + ) + + def _analyze_side( + self, + levels: list["Level"], + _is_bid: bool, + ) -> LiquidityInfo: + """ + Analyze liquidity on one side of the order book. + + Args: + levels: List of price levels from the order book. + is_bid: True for bid side, False for ask side. + + Returns: + LiquidityInfo with analysis results. + """ + if not levels: + return LiquidityInfo( + has_liquidity=False, + best_price=None, + total_qty=Decimal("0"), + levels=[], + within_circuit_breaker=False, + ) + + best_price = Decimal(levels[0].px) + total_qty = sum((Decimal(level.qty) for level in levels), Decimal("0")) + + # Check if best price is within circuit breaker range + floor = self._oracle_price * (1 - CIRCUIT_BREAKER_PCT) + ceiling = self._oracle_price * (1 + CIRCUIT_BREAKER_PCT) + within_cb = floor <= best_price <= ceiling + + return LiquidityInfo( + has_liquidity=True, + best_price=best_price, + total_qty=total_qty, + levels=levels, + within_circuit_breaker=within_cb, + ) + + def get_usable_bid_price(self, state: OrderBookState, min_qty: str) -> Optional[Decimal]: + """ + Get best bid price with sufficient quantity within circuit breaker. + + Args: + state: Current order book state. + min_qty: Minimum required quantity. + + Returns: + Best usable bid price, or None if no usable liquidity. + """ + if not state.has_usable_bid_liquidity: + return None + + min_qty_dec = Decimal(min_qty) + cumulative_qty = Decimal("0") + + for level in state.bids.levels: + price = Decimal(level.px) + qty = Decimal(level.qty) + + # Check if this level is within circuit breaker + if not state.circuit_breaker_floor <= price <= state.circuit_breaker_ceiling: + continue + + cumulative_qty += qty + if cumulative_qty >= min_qty_dec: + return state.bids.best_price + + return None + + def get_usable_ask_price(self, state: OrderBookState, min_qty: str) -> Optional[Decimal]: + """ + Get best ask price with sufficient quantity within circuit breaker. + + Args: + state: Current order book state. + min_qty: Minimum required quantity. + + Returns: + Best usable ask price, or None if no usable liquidity. + """ + if not state.has_usable_ask_liquidity: + return None + + min_qty_dec = Decimal(min_qty) + cumulative_qty = Decimal("0") + + for level in state.asks.levels: + price = Decimal(level.px) + qty = Decimal(level.qty) + + # Check if this level is within circuit breaker + if not state.circuit_breaker_floor <= price <= state.circuit_breaker_ceiling: + continue + + cumulative_qty += qty + if cumulative_qty >= min_qty_dec: + return state.asks.best_price + + return None + + def get_safe_no_match_buy_price(self, _state: OrderBookState) -> Decimal: + """ + Get a buy price guaranteed not to match any existing asks. + + Uses an extreme low price ($10) that is far below any realistic market price, + ensuring the order will never match regardless of order book state. + + Args: + state: Current order book state (unused, kept for API compatibility). + + Returns: + $10 - a safe buy price that will never match. + """ + return SAFE_NO_MATCH_BUY_PRICE + + def get_safe_no_match_sell_price(self, _state: OrderBookState) -> Decimal: + """ + Get a sell price guaranteed not to match any existing bids. + + Uses an extreme high price ($10M) that is far above any realistic market price, + ensuring the order will never match regardless of order book state. + + Args: + state: Current order book state (unused, kept for API compatibility). + + Returns: + $10,000,000 - a safe sell price that will never match. + """ + return SAFE_NO_MATCH_SELL_PRICE + + def get_qty_at_price_or_better(self, state: OrderBookState, side: str, price: Decimal) -> Decimal: + """ + Get total quantity available at a price or better. + + Args: + state: Current order book state. + side: 'bid' or 'ask'. + price: Target price. + + Returns: + Total quantity available at price or better. + """ + levels = state.bids.levels if side == "bid" else state.asks.levels + total_qty = Decimal("0") + + for level in levels: + level_price = Decimal(level.px) + level_qty = Decimal(level.qty) + + if side == "bid": + # For bids, "better" means higher price + if level_price >= price: + total_qty += level_qty + else: + # For asks, "better" means lower price + if level_price <= price: + total_qty += level_qty + + return total_qty + + +def log_order_book_state(state: OrderBookState) -> None: + """Log a summary of the order book state.""" + logger.info(f"Order book state for {state.symbol}:") + logger.info(f" Oracle price: ${state.oracle_price:.2f}") + logger.info(f" Circuit breaker range: ${state.circuit_breaker_floor:.2f} - ${state.circuit_breaker_ceiling:.2f}") + + if state.bids.has_liquidity: + logger.info( + f" Bids: best=${state.bids.best_price:.2f}, " + f"total_qty={state.bids.total_qty}, " + f"usable={state.bids.is_usable}" + ) + else: + logger.info(" Bids: empty") + + if state.asks.has_liquidity: + logger.info( + f" Asks: best=${state.asks.best_price:.2f}, " + f"total_qty={state.asks.total_qty}, " + f"usable={state.asks.is_usable}" + ) + else: + logger.info(" Asks: empty") diff --git a/tests/helpers/reya_tester/__init__.py b/tests/helpers/reya_tester/__init__.py new file mode 100644 index 00000000..b4e418ac --- /dev/null +++ b/tests/helpers/reya_tester/__init__.py @@ -0,0 +1,16 @@ +"""ReyaTester - Composition-based integration test helper.""" + +# Re-export logger for backward compatibility +import logging + +from .tester import ReyaTester +from .utils import limit_order_params_to_order, trigger_order_params_to_order + +logger = logging.getLogger("reya.integration_tests") + +__all__ = [ + "ReyaTester", + "limit_order_params_to_order", + "trigger_order_params_to_order", + "logger", +] diff --git a/tests/helpers/reya_tester/checks.py b/tests/helpers/reya_tester/checks.py new file mode 100644 index 00000000..4c6e8ad7 --- /dev/null +++ b/tests/helpers/reya_tester/checks.py @@ -0,0 +1,392 @@ +"""Assertion/check operations for ReyaTester.""" + +from typing import TYPE_CHECKING, Optional, Union + +import asyncio +import logging +import os + +import pytest + +from sdk.async_api.account_balance import AccountBalance as AsyncAccountBalance +from sdk.async_api.order import Order as AsyncOrder +from sdk.async_api.spot_execution import SpotExecution as AsyncSpotExecution +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.execution_type import ExecutionType +from sdk.open_api.models.order import Order +from sdk.open_api.models.order_status import OrderStatus +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.perp_execution import PerpExecution +from sdk.open_api.models.side import Side +from sdk.open_api.models.spot_execution import SpotExecution + +if TYPE_CHECKING: + from .tester import ReyaTester + +logger = logging.getLogger("reya.integration_tests") + + +class Checks: + """Assertion operations for verifying test state.""" + + def __init__(self, tester: "ReyaTester"): + self._t = tester + + async def open_order_created(self, order_id: Optional[str], expected_order: Order) -> None: + """Verify an open order was created with expected values.""" + if order_id is None: + raise ValueError("order_id is required for open_order_created (got None)") + 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]: + ws_order = self._t.ws.orders.get(str(order_id)) + if ws_order: + open_order = ws_order + logger.info(f"✅ Trigger order found via WebSocket: {open_order}") + + assert ( + open_order is not None + ), f"check_open_order_created: Order {order_id} was not found in open orders or WebSocket" + assert open_order.order_id == order_id, "check_open_order_created: Wrong order id" + + if expected_order.order_type == OrderType.LIMIT: + assert float(open_order.limit_px) == float( + expected_order.limit_px + ), f"check_open_order_created: Wrong limit price. Expected: {expected_order.limit_px}, Got: {open_order.limit_px}" + assert open_order.qty is not None and expected_order.qty is not None + assert float(open_order.qty) == float( + expected_order.qty + ), f"check_open_order_created: Wrong qty. Expected: {expected_order.qty}, Got: {open_order.qty}" + else: + expected_trigger_px = expected_order.trigger_px if expected_order.trigger_px else expected_order.limit_px + assert open_order.trigger_px is not None + assert float(open_order.trigger_px) == pytest.approx( + float(expected_trigger_px), rel=1e-6 + ), f"check_open_order_created: Wrong trigger price. Expected: {expected_trigger_px}, Got: {open_order.trigger_px}" + assert open_order.qty is None, "check_open_order_created: Has qty" + + assert open_order.order_type == expected_order.order_type, "check_open_order_created: Wrong order type" + assert open_order.side == expected_order.side, "check_open_order_created: Wrong order direction" + assert open_order.status == OrderStatus.OPEN, "check_open_order_created: Wrong order status" + + async def no_open_orders(self) -> None: + """Assert no open orders exist. + + Note: + Set SPOT_PRESERVE_ACCOUNT1_ORDERS=true to skip this check for SPOT_ACCOUNT_ID_1. + This is useful when testing with external liquidity from a depth script. + """ + # Check if we should preserve orders for SPOT account 1 + preserve_account1 = os.getenv("SPOT_PRESERVE_ACCOUNT1_ORDERS", "").lower() == "true" + if preserve_account1 and self._t.spot_account_number == 1: + logger.info("⚠️ SPOT_PRESERVE_ACCOUNT1_ORDERS=true: Skipping no_open_orders check for SPOT account 1") + return + + open_orders = await self._t.client.get_open_orders() + if len(open_orders) == 0: + return + + logger.warning( + f"check_no_open_orders: Found {len(open_orders)} open orders from database, checking if they're stale:" + ) + + legitimate_orders = [] + for order in open_orders: + logger.warning(f" - Checking order ID: {order.order_id}, Symbol: {order.symbol}, Status: {order.status}") + try: + await self._t.client.cancel_order( + order_id=order.order_id, symbol=order.symbol, account_id=order.account_id + ) + 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): + 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}") + legitimate_orders.append(order) + + if len(legitimate_orders) == 0: + logger.info("check_no_open_orders: All orders are stale, test can proceed") + return + + logger.warning(f"Waiting for {len(legitimate_orders)} legitimate orders to be cancelled...") + await asyncio.sleep(0.05) + + remaining_orders = await self._t.client.get_open_orders() + remaining_legitimate = [] + for order in remaining_orders: + try: + await self._t.client.cancel_order( + order_id=order.order_id, symbol=order.symbol, account_id=order.account_id + ) + except ApiException as e: + if "Missing order" not in str(e): + remaining_legitimate.append(order) + + if len(remaining_legitimate) > 0: + logger.error(f"check_no_open_orders: Still found {len(remaining_legitimate)} legitimate open orders:") + for order in remaining_legitimate: + logger.error(f" - Order ID: {order.order_id}, Symbol: {order.symbol}, Status: {order.status}") + assert False, "check_no_open_orders: Open orders should be empty" + else: + logger.info("check_no_open_orders: All legitimate orders cleaned up successfully") + + async def position( + self, + symbol: str, + expected_exchange_id: int, + expected_account_id: int, + expected_qty: str, + expected_side: Side, + expected_avg_entry_price: Optional[str] = None, + expected_last_trade_sequence_number: Optional[int] = None, + ) -> None: + """Verify position exists with expected values.""" + pos = await self._t.data.position(symbol) + if pos is None: + raise RuntimeError("check_position: Position not found") + + if expected_exchange_id is not None: + assert pos.exchange_id == expected_exchange_id, "check_position: Exchange ID does not match" + if expected_account_id is not None: + assert pos.account_id == expected_account_id, "check_position: Account ID does not match" + if expected_qty is not None: + assert pos.qty == expected_qty, "check_position: Qty does not match" + if expected_side is not None: + assert pos.side == expected_side, "check_position: Side does not match" + if expected_avg_entry_price is not None: + assert float(pos.avg_entry_price) == pytest.approx( + float(expected_avg_entry_price), rel=1e-6 + ), "check_position: Average entry price does not match" + if expected_last_trade_sequence_number is not None: + assert ( + pos.last_trade_sequence_number == expected_last_trade_sequence_number + ), "check_position: Last trade sequence number does not match" + + async def position_not_open(self, symbol: str) -> None: + """Assert position is closed via both REST and WebSocket.""" + pos = await self._t.data.position(symbol) + assert pos is None, "check_position_not_open: Position should be empty" + + ws_position = self._t.ws.positions.get(symbol) + assert ( + ws_position is None or ws_position.qty == "0" + ), "check_position_not_open: WebSocket position should be empty" + + async def order_execution( + self, order_execution: PerpExecution, expected_order: Order, expected_qty: Optional[str] = None + ) -> PerpExecution: + """Validate perp order execution details.""" + assert order_execution is not None, "check_order_execution: No order execution found" + assert ( + order_execution.exchange_id == self._t.client.config.dex_id + ), "check_order_execution: Exchange ID does not match" + 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 + ), "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 ( + order_execution.type == ExecutionType.ORDER_MATCH + ), "check_order_execution: Order execution type does not match" + if expected_order.order_type == OrderType.LIMIT: + assert expected_order.limit_px is not None + if expected_order.side == Side.B: + assert float(order_execution.price) <= float( + expected_order.limit_px + ), "check_order_execution: Order execution price does not match" + else: + assert float(order_execution.price) >= float( + expected_order.limit_px + ), "check_order_execution: Order execution price does not match" + return order_execution + + async def no_order_execution_since(self, since_sequence_number: int) -> None: + """Assert no order execution occurred since the given sequence number. + + Args: + since_sequence_number: The sequence number to compare against. + Only executions with sequence_number > since_sequence_number + are considered "new" executions. + """ + order_execution = await self._t.data.last_perp_execution() + if order_execution is not None: + assert ( + order_execution.sequence_number <= since_sequence_number + ), f"check_no_order_execution_since: Found new execution with sequence_number {order_execution.sequence_number} > {since_sequence_number}" + + async def spot_execution( + self, spot_execution: SpotExecution, expected_order: Order, expected_qty: Optional[str] = None + ) -> SpotExecution: + """Validate spot execution details.""" + assert spot_execution is not None, "check_spot_execution: No spot execution found" + assert ( + spot_execution.exchange_id == self._t.client.config.dex_id + ), "check_spot_execution: Exchange ID does not match" + assert spot_execution.symbol == expected_order.symbol, "check_spot_execution: Symbol does not match" + assert spot_execution.account_id == expected_order.account_id, "check_spot_execution: Account ID does not match" + assert spot_execution.qty == ( + expected_order.qty if expected_qty is None else expected_qty + ), "check_spot_execution: Quantity does not match" + assert spot_execution.side == expected_order.side, "check_spot_execution: Side does not match" + assert spot_execution.type == ExecutionType.ORDER_MATCH, "check_spot_execution: Execution type does not match" + if expected_order.order_type == OrderType.LIMIT and expected_order.limit_px is not None: + if expected_order.side == Side.B: + assert float(spot_execution.price) <= float( + expected_order.limit_px + ), "check_spot_execution: Execution price should be <= limit price for buy" + else: + assert float(spot_execution.price) >= float( + expected_order.limit_px + ), "check_spot_execution: Execution price should be >= limit price for sell" + return spot_execution + + async def balance( + self, + asset: str, + expected_account_id: int, + expected_min_balance: Optional[str] = None, + expected_max_balance: Optional[str] = None, + ) -> None: + """Check account balance for a specific asset.""" + bal = await self._t.data.balance(asset) + if bal is None: + raise RuntimeError(f"check_balance: Balance not found for asset {asset}") + + if expected_account_id is not None: + assert bal.account_id == expected_account_id, "check_balance: Account ID does not match" + + if expected_min_balance is not None: + assert float(bal.real_balance) >= float( + expected_min_balance + ), f"check_balance: Balance {bal.real_balance} should be >= {expected_min_balance}" + + if expected_max_balance is not None: + assert float(bal.real_balance) <= float( + expected_max_balance + ), f"check_balance: Balance {bal.real_balance} should be <= {expected_max_balance}" + + def ws_order_change_received( + self, + order_id: Optional[str], + expected_symbol: Optional[str] = None, + expected_side: Optional[str] = None, + expected_status: Optional[OrderStatus] = None, + expected_qty: Optional[str] = None, + ) -> AsyncOrder: + """Assert that an order change event was received via WebSocket.""" + if order_id is None: + raise ValueError("order_id is required for ws_order_change_received (got None)") + assert order_id in self._t.ws.order_changes, ( + f"Order {order_id} not found in WebSocket order changes. " + f"Available orders: {list(self._t.ws.order_changes.keys())}" + ) + + ws_order = self._t.ws.order_changes[order_id] + logger.info(f"✅ Order change event received via WebSocket for {order_id}") + + if expected_symbol is not None: + assert ( + ws_order.symbol == expected_symbol + ), f"Symbol mismatch: expected {expected_symbol}, got {ws_order.symbol}" + logger.info(f" ✅ Symbol: {ws_order.symbol}") + + if expected_side is not None: + # Compare enum value (string) since async_api uses enum types + ws_side_value = ws_order.side.value if hasattr(ws_order.side, "value") else ws_order.side + assert ws_side_value == expected_side, f"Side mismatch: expected {expected_side}, got {ws_side_value}" + side_name = "BUY" if expected_side == "B" else "SELL" + logger.info(f" ✅ Side: {ws_side_value} ({side_name})") + + if expected_status is not None: + # Compare enum value (string) since async_api uses enum types + ws_status_value = ws_order.status.value if hasattr(ws_order.status, "value") else ws_order.status + assert ( + ws_status_value == expected_status + ), f"Status mismatch: expected {expected_status}, got {ws_status_value}" + logger.info(f" ✅ Status: {ws_status_value}") + + if expected_qty is not None: + assert ws_order.qty is not None, "ws_order.qty is None" + ws_qty = float(ws_order.qty) + exp_qty = float(expected_qty) + assert abs(ws_qty - exp_qty) < 0.0001, f"Qty mismatch: expected {exp_qty}, got {ws_qty}" + logger.info(f" ✅ Qty: {ws_order.qty}") + + return ws_order + + def ws_spot_execution_received( + self, + expected_symbol: Optional[str] = None, + expected_side: Optional[str] = None, + expected_qty: Optional[str] = None, + expected_price: Optional[str] = None, + ) -> AsyncSpotExecution: + """Assert that a spot execution event was received via WebSocket.""" + assert self._t.ws.last_spot_execution is not None, "No spot execution event received via WebSocket" + + execution = self._t.ws.last_spot_execution + logger.info("✅ Spot execution event received via WebSocket") + + if expected_symbol is not None: + assert ( + execution.symbol == expected_symbol + ), f"Symbol mismatch: expected {expected_symbol}, got {execution.symbol}" + logger.info(f" ✅ Symbol: {execution.symbol}") + + if expected_side is not None: + # Compare enum value (string) since async_api uses enum types + exec_side_value = execution.side.value if hasattr(execution.side, "value") else execution.side + assert exec_side_value == expected_side, f"Side mismatch: expected {expected_side}, got {exec_side_value}" + side_name = "BUY" if expected_side == "B" else "SELL" + logger.info(f" ✅ Side: {exec_side_value} ({side_name})") + + if expected_qty is not None: + exec_qty = float(execution.qty) + exp_qty = float(expected_qty) + assert abs(exec_qty - exp_qty) < 1e-9, f"Qty mismatch: expected {exp_qty}, got {exec_qty}" + logger.info(f" ✅ Qty: {execution.qty}") + + if expected_price is not None and hasattr(execution, "price") and execution.price: + exec_price = float(execution.price) + exp_price = float(expected_price) + assert abs(exec_price - exp_price) < 1e-9, f"Price mismatch: expected {exp_price}, got {exec_price}" + logger.info(f" ✅ Price: {execution.price}") + + return execution + + def ws_balance_updates_received( + self, + initial_update_count: int, + min_updates: int = 1, + expected_assets: Optional[list[str]] = None, + ) -> list[AsyncAccountBalance]: + """Assert that balance update events were received via WebSocket.""" + new_update_count = len(self._t.ws.balance_updates) - initial_update_count + + assert ( + new_update_count >= min_updates + ), f"Expected at least {min_updates} balance update(s), got {new_update_count}" + logger.info(f"✅ Received {new_update_count} balance update(s) via WebSocket") + + new_updates = self._t.ws.balance_updates[initial_update_count:] + + account_updates = [u for u in new_updates if u.account_id == self._t.account_id] + assets_updated = {u.asset for u in account_updates} + logger.info(f" Assets updated: {assets_updated}") + + if expected_assets is not None: + for asset in expected_assets: + if asset in assets_updated: + logger.info(f" ✅ {asset} balance update received") + else: + logger.warning(f" ⚠️ {asset} balance update not found (may be delayed)") + + return new_updates diff --git a/tests/helpers/reya_tester/data.py b/tests/helpers/reya_tester/data.py new file mode 100644 index 00000000..cb5421cc --- /dev/null +++ b/tests/helpers/reya_tester/data.py @@ -0,0 +1,129 @@ +"""Data retrieval operations for ReyaTester.""" + +from typing import TYPE_CHECKING, Optional + +import logging + +from sdk.open_api.models.account_balance import AccountBalance +from sdk.open_api.models.depth import Depth +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 +from sdk.open_api.models.perp_execution_list import PerpExecutionList +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_list import SpotExecutionList + +if TYPE_CHECKING: + from .tester import ReyaTester + +logger = logging.getLogger("reya.integration_tests") + + +class DataOperations: + """Data retrieval operations (get_* methods).""" + + 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 + + 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") + + async def positions(self) -> dict[str, Position]: + """Get all current positions.""" + positions_list: list[Position] = await self._t.client.get_positions() + + position_summary = {} + for position in positions_list: + symbol = position.symbol + qty = position.qty + if symbol and qty: + position_summary[symbol] = position + + return position_summary + + async def position(self, symbol: str) -> Optional[Position]: + """Get current position for a specific market.""" + positions = await self.positions() + return positions.get(symbol) + + async def last_perp_execution(self) -> Optional[PerpExecution]: + """Get the most recent perp execution for this wallet. + + Returns None if no executions exist. + """ + wallet_address = self._t.owner_wallet_address + if wallet_address is None: + raise ValueError("owner_wallet_address is required for perp execution lookup") + trades_list: PerpExecutionList = await self._t.client.wallet.get_wallet_perp_executions(address=wallet_address) + if trades_list.data and len(trades_list.data) > 0: + return trades_list.data[0] + return None + + async def last_spot_execution(self) -> Optional[SpotExecution]: + """Get the most recent spot execution for this wallet. + + Returns None if no executions exist. + """ + wallet_address = self._t.owner_wallet_address + if wallet_address is None: + raise ValueError("owner_wallet_address is required for spot execution lookup") + executions_list: SpotExecutionList = await self._t.client.wallet.get_wallet_spot_executions( + address=wallet_address + ) + if executions_list.data and len(executions_list.data) > 0: + return executions_list.data[0] + return None + + async def balances(self) -> dict[str, AccountBalance]: + """Get current account balances for this tester's account only.""" + balances_list: list[AccountBalance] = await self._t.client.get_account_balances() + + balance_dict = {} + for balance in balances_list: + if balance.account_id == self._t.account_id: + asset = balance.asset + if asset: + balance_dict[asset] = balance + + return balance_dict + + async def balance(self, asset: str) -> Optional[AccountBalance]: + """Get balance for a specific asset.""" + balances = await self.balances() + return balances.get(asset) + + async def market_depth(self, symbol: str) -> Depth: + """Get L2 market depth (orderbook) for a given symbol via REST API.""" + return await self._t.client.markets.get_market_depth(symbol=symbol) + + async def market_definition(self, symbol: str) -> MarketDefinition: + """Get market configuration for a specific symbol.""" + markets_config: list[MarketDefinition] = await self._t.client.reference.get_market_definitions() + for config in markets_config: + if config.symbol == symbol: + return config + raise RuntimeError(f"Market definition not found for symbol: {symbol}") + + async def open_order(self, order_id: str) -> Optional[Order]: + """Get a specific open order by ID.""" + open_orders = await self._t.client.get_open_orders() + for order in open_orders: + if order.order_id == order_id: + return order + return None + + async def open_orders(self) -> list[Order]: + """Get all open orders.""" + return await self._t.client.get_open_orders() diff --git a/tests/helpers/reya_tester/matchers.py b/tests/helpers/reya_tester/matchers.py new file mode 100644 index 00000000..3a8e2a2d --- /dev/null +++ b/tests/helpers/reya_tester/matchers.py @@ -0,0 +1,212 @@ +"""Unified matching logic for executions and orders. + +This module provides a single source of truth for matching WebSocket/REST +events against expected values, supporting both perp and spot execution types. +""" + +from typing import Any, Optional, Union + +from sdk.async_api.order import Order as AsyncOrder +from sdk.async_api.perp_execution import PerpExecution as AsyncPerpExecution +from sdk.async_api.spot_execution import SpotExecution as AsyncSpotExecution +from sdk.open_api.models.order import Order +from sdk.open_api.models.perp_execution import PerpExecution +from sdk.open_api.models.spot_execution import SpotExecution + + +def _get_enum_value(val: Any) -> str: + """Extract string value from enum or return string as-is.""" + if val is None: + return "" + return val.value if hasattr(val, "value") else str(val) + + +def _compare_qty(qty1: Optional[str], qty2: Optional[str]) -> bool: + """Compare quantities with tolerance for floating point differences.""" + if qty1 is None or qty2 is None: + return qty1 == qty2 + try: + return abs(float(qty1) - float(qty2)) < 1e-9 + except (ValueError, TypeError): + return qty1 == qty2 + + +class ExecutionMatcher: + """Unified matching for perp and spot executions. + + Provides static methods for matching executions against expected orders, + handling the differences between perp (no order_id) and spot (has order_id). + """ + + @staticmethod + def match_perp( + execution: Union[PerpExecution, AsyncPerpExecution], + expected: Order, + expected_qty: Optional[str] = None, + ) -> bool: + """Match a perp execution against expected order values. + + Note: PerpExecution does NOT have order_id, so we match by: + - account_id, symbol, side, qty + + Args: + execution: The perp execution to check. + expected: The expected order to match against. + expected_qty: Optional override for qty comparison (used for closing orders). + + Returns: + True if execution matches expected values. + """ + if execution.account_id != expected.account_id: + return False + if execution.symbol != expected.symbol: + return False + if _get_enum_value(execution.side) != _get_enum_value(expected.side): + return False + + qty_to_match = expected_qty if expected_qty is not None else expected.qty + if not _compare_qty(execution.qty, qty_to_match): + return False + + return True + + @staticmethod + def match_spot( + execution: Union[SpotExecution, AsyncSpotExecution], + expected: Order, + ) -> bool: + """Match a spot execution against expected values. + + Performs strict matching on ALL important fields: + - order_id, account_id, symbol, side, qty + + Args: + execution: The spot execution to check. + expected: The expected order to match against. + + Returns: + True if execution matches expected values. + """ + if str(execution.order_id) != str(expected.order_id): + return False + if execution.account_id != expected.account_id: + return False + if execution.symbol != expected.symbol: + return False + if _get_enum_value(execution.side) != _get_enum_value(expected.side): + return False + if not _compare_qty(execution.qty, expected.qty): + return False + + return True + + +class ValidationResult: + """Result of field validation with error details.""" + + def __init__(self) -> None: + self._errors: list[str] = [] + + def add_error(self, message: str) -> None: + """Add a validation error.""" + self._errors.append(message) + + @property + def is_valid(self) -> bool: + """Return True if no validation errors.""" + return len(self._errors) == 0 + + @property + def errors(self) -> list[str]: + """Return list of validation error messages.""" + return self._errors.copy() + + +class FieldValidator: + """Validates execution fields against expected values with detailed errors.""" + + @staticmethod + def validate_spot_execution( + execution: Union[SpotExecution, AsyncSpotExecution], + expected: Order, + ) -> ValidationResult: + """Validate all key fields of a spot execution. + + Args: + execution: The spot execution to validate. + expected: The expected order values. + + Returns: + ValidationResult with any errors found. + """ + result = ValidationResult() + + if str(execution.order_id) != str(expected.order_id): + result.add_error(f"order_id mismatch: expected {expected.order_id}, got {execution.order_id}") + if execution.account_id != expected.account_id: + result.add_error(f"account_id mismatch: expected {expected.account_id}, got {execution.account_id}") + if execution.symbol != expected.symbol: + result.add_error(f"symbol mismatch: expected {expected.symbol}, got {execution.symbol}") + + exec_side = _get_enum_value(execution.side) + expected_side = _get_enum_value(expected.side) + if exec_side != expected_side: + result.add_error(f"side mismatch: expected {expected_side}, got {exec_side}") + + if not _compare_qty(execution.qty, expected.qty): + result.add_error(f"qty mismatch: expected {expected.qty}, got {execution.qty}") + + return result + + @staticmethod + def validate_order_change( + order: AsyncOrder, + expected: Order, + ) -> ValidationResult: + """Validate all key fields of a WebSocket order change. + + Args: + order: The WebSocket order change to validate. + expected: The expected order values. + + Returns: + ValidationResult with any errors found. + """ + result = ValidationResult() + + if str(order.order_id) != str(expected.order_id): + result.add_error(f"order_id mismatch: expected {expected.order_id}, got {order.order_id}") + if order.account_id != expected.account_id: + result.add_error(f"account_id mismatch: expected {expected.account_id}, got {order.account_id}") + if order.symbol != expected.symbol: + result.add_error(f"symbol mismatch: expected {expected.symbol}, got {order.symbol}") + + order_side = _get_enum_value(order.side) + expected_side = _get_enum_value(expected.side) + if order_side != expected_side: + result.add_error(f"side mismatch: expected {expected_side}, got {order_side}") + + if expected.qty and hasattr(order, "qty") and order.qty: + if not _compare_qty(order.qty, expected.qty): + result.add_error(f"qty mismatch: expected {expected.qty}, got {order.qty}") + + if expected.order_type and hasattr(order, "order_type") and order.order_type: + order_type = _get_enum_value(order.order_type) + expected_type = _get_enum_value(expected.order_type) + if order_type != expected_type: + result.add_error(f"order_type mismatch: expected {expected_type}, got {order_type}") + + if expected.time_in_force and hasattr(order, "time_in_force") and order.time_in_force: + order_tif = _get_enum_value(order.time_in_force) + expected_tif = _get_enum_value(expected.time_in_force) + if order_tif != expected_tif: + result.add_error(f"time_in_force mismatch: expected {expected_tif}, got {order_tif}") + + if expected.limit_px and hasattr(order, "limit_px") and order.limit_px: + try: + if abs(float(order.limit_px) - float(expected.limit_px)) > 1e-6: + result.add_error(f"limit_px mismatch: expected {expected.limit_px}, got {order.limit_px}") + except (ValueError, TypeError): + pass + + return result diff --git a/tests/helpers/reya_tester/orders.py b/tests/helpers/reya_tester/orders.py new file mode 100644 index 00000000..d43e7514 --- /dev/null +++ b/tests/helpers/reya_tester/orders.py @@ -0,0 +1,140 @@ +"""Order operations for ReyaTester.""" + +from typing import TYPE_CHECKING, Optional + +import asyncio +import logging +import os + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.create_order_response import CreateOrderResponse +from sdk.open_api.models.order import Order +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api.models import LimitOrderParameters, TriggerOrderParameters + +from .retry import with_retry + +if TYPE_CHECKING: + from .tester import ReyaTester + +logger = logging.getLogger("reya.integration_tests") + + +class OrderOperations: + """Order creation and management operations.""" + + def __init__(self, tester: "ReyaTester"): + self._t = tester + + async def create_limit(self, params: LimitOrderParameters) -> Optional[str]: + """Create a limit order with the specified parameters.""" + side_text = "BUY" if params.is_buy else "SELL" + time_in_force_text = "IOC" if params.time_in_force == TimeInForce.IOC else "GTC" + + logger.info( + f"📤 Creating {time_in_force_text} {side_text} order: symbol={params.symbol}, price=${params.limit_px}, qty={params.qty}" + ) + + response: CreateOrderResponse = await with_retry( + lambda: self._t.client.create_limit_order(params), + max_retries=3, + retry_delay=1.0, + operation_name=f"create_limit_order({params.symbol})", + ) + logger.info(f"Response: {response}") + + return response.order_id + + async def create_trigger(self, params: TriggerOrderParameters) -> CreateOrderResponse: + """Create a trigger order (TP/SL) with the specified parameters.""" + side_text = "BUY" if params.is_buy else "SELL" + trigger_type_text = params.trigger_type.value + + logger.info( + f"📤 Creating {trigger_type_text} {side_text} order: symbol={params.symbol}, trigger_px=${params.trigger_px}" + ) + + response: CreateOrderResponse = await with_retry( + lambda: self._t.client.create_trigger_order(params), + max_retries=3, + retry_delay=1.0, + operation_name=f"create_trigger_order({params.symbol})", + ) + + logger.info(f"✅ {trigger_type_text} {side_text} order created with ID: {response.order_id}") + return response + + async def cancel(self, order_id: str, symbol: str, account_id: int) -> None: + """Cancel a specific order.""" + await self._t.client.cancel_order(order_id=order_id, symbol=symbol, account_id=account_id) + + async def close_all(self, fail_if_none: bool = True, wait_for_confirmation: bool = False) -> None: + """Cancel all active orders. + + Uses mass_cancel for spot markets to avoid nonce race conditions. + + Args: + fail_if_none: If True, assert failure when no orders to close + wait_for_confirmation: If True, wait for cancellation confirmation (slower but safer) + + Note: + Set SPOT_PRESERVE_ACCOUNT1_ORDERS=true to skip order cancellation for SPOT_ACCOUNT_ID_1. + This is useful when testing with external liquidity from a depth script. + """ + # Check if we should preserve orders for SPOT account 1 + preserve_account1 = os.getenv("SPOT_PRESERVE_ACCOUNT1_ORDERS", "").lower() == "true" + if preserve_account1 and self._t.spot_account_number == 1: + logger.info("⚠️ SPOT_PRESERVE_ACCOUNT1_ORDERS=true: Skipping close_all for SPOT account 1") + return None + + active_orders: list[Order] = await self._t.client.get_open_orders() + + if active_orders is None or len(active_orders) == 0: + logger.warning("No active orders to close") + if fail_if_none: + assert False + return None + + # Group orders by symbol for mass cancel + orders_by_symbol: dict[str, list[Order]] = {} + for order in active_orders: + if order.symbol not in orders_by_symbol: + orders_by_symbol[order.symbol] = [] + orders_by_symbol[order.symbol].append(order) + + cancelled_count = 0 + for symbol, orders in orders_by_symbol.items(): + try: + # Use mass_cancel for spot markets (single nonce per symbol) + if not symbol.upper().endswith("PERP"): + await self._t.client.mass_cancel(symbol=symbol, account_id=self._t.account_id) + cancelled_count += len(orders) + logger.info(f"Mass cancelled {len(orders)} orders for {symbol}") + else: + # For perp markets, cancel individually + for order in orders: + try: + await self._t.client.cancel_order( + order_id=order.order_id, symbol=order.symbol, account_id=order.account_id + ) + cancelled_count += 1 + except ApiException as e: + logger.warning(f"Failed to cancel order {order.order_id}: {e}") + except ApiException as e: + logger.warning(f"Failed to mass cancel orders for {symbol}: {e}") + # Fall back to individual cancellation + for order in orders: + try: + await self._t.client.cancel_order( + order_id=order.order_id, symbol=order.symbol, account_id=order.account_id + ) + cancelled_count += 1 + except ApiException as e2: + logger.warning(f"Failed to cancel order {order.order_id}: {e2}") + + if fail_if_none and cancelled_count == 0: + assert False, "Failed to close any orders" + + # Brief pause to let cancellations propagate + if cancelled_count > 0 and not wait_for_confirmation: + await asyncio.sleep(0.2) diff --git a/tests/helpers/reya_tester/perp_trade_context.py b/tests/helpers/reya_tester/perp_trade_context.py new file mode 100644 index 00000000..3c24a7fe --- /dev/null +++ b/tests/helpers/reya_tester/perp_trade_context.py @@ -0,0 +1,339 @@ +"""Context manager for bulletproof PERP trade verification. + +This module provides a robust, industry-standard approach to verifying +PERP trade executions across both REST and WebSocket channels. + +The key insight: For the SAME trade, REST and WS will ALWAYS return +the SAME sequence_number. We use this as the correlation mechanism. + +Usage: + async with reya_tester.perp_trade() as ctx: + # Baseline is automatically captured here + await reya_tester.create_limit_order(params) + + # Wait for execution - finds first new trade matching criteria + execution = await ctx.wait_for_execution(expected_order) + # execution is guaranteed to be the SAME trade on both REST and WS +""" + +from typing import TYPE_CHECKING, Any, Optional + +import asyncio +import logging +import time +from dataclasses import dataclass, field + +from sdk.async_api.perp_execution import PerpExecution as AsyncPerpExecution +from sdk.open_api.models.order import Order +from sdk.open_api.models.perp_execution import PerpExecution +from sdk.open_api.models.perp_execution_list import PerpExecutionList + +if TYPE_CHECKING: + from .tester import ReyaTester + +logger = logging.getLogger("reya.integration_tests") + + +def _get_enum_value(val: Any) -> str: + """Extract string value from enum or return string as-is.""" + if val is None: + return "" + return val.value if hasattr(val, "value") else str(val) + + +def _compare_qty(qty1: Optional[str], qty2: Optional[str]) -> bool: + """Compare quantities with tolerance for floating point differences.""" + if qty1 is None or qty2 is None: + return qty1 == qty2 + try: + return abs(float(qty1) - float(qty2)) < 1e-9 + except (ValueError, TypeError): + return qty1 == qty2 + + +@dataclass +class TradeVerificationResult: + """Result of trade verification across REST and WS.""" + + rest_execution: PerpExecution + ws_execution: AsyncPerpExecution + sequence_number: int + elapsed_time: float + + @property + def is_consistent(self) -> bool: + """Check if REST and WS executions are consistent.""" + return self.rest_execution.sequence_number == self.ws_execution.sequence_number + + +@dataclass +class PerpTradeContext: + """Context for tracking and verifying a single PERP trade. + + This class captures the baseline sequence number BEFORE an order is placed, + then provides methods to wait for and verify the resulting execution. + + The key guarantee: When wait_for_execution() returns, you have verified + that the EXACT SAME trade was seen on both REST and WS channels. + """ + + tester: "ReyaTester" + baseline_seq: int = 0 + _entered: bool = field(default=False, repr=False) + + async def __aenter__(self) -> "PerpTradeContext": + """Capture baseline sequence number on context entry.""" + self.baseline_seq = await self._get_current_max_sequence() + self._entered = True + logger.debug(f"📍 PerpTradeContext: baseline_seq={self.baseline_seq}") + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + """Clean up on context exit.""" + # No cleanup needed - context just tracks baseline sequence + + async def _get_current_max_sequence(self) -> int: + """Get the current maximum sequence number from REST API.""" + wallet_address = self.tester.owner_wallet_address + if wallet_address is None: + return 0 + + trades_list: PerpExecutionList = await self.tester.client.wallet.get_wallet_perp_executions( + address=wallet_address + ) + if trades_list.data and len(trades_list.data) > 0: + return trades_list.data[0].sequence_number + return 0 + + async def _fetch_rest_execution_by_sequence(self, target_seq: int) -> Optional[PerpExecution]: + """Fetch a specific execution from REST by sequence number.""" + wallet_address = self.tester.owner_wallet_address + if wallet_address is None: + return None + + trades_list: PerpExecutionList = await self.tester.client.wallet.get_wallet_perp_executions( + address=wallet_address + ) + for trade in trades_list.data: + if trade.sequence_number == target_seq: + return trade + return None + + def _find_ws_execution( + self, + expected_order: Order, + expected_qty: Optional[str] = None, + ) -> Optional[AsyncPerpExecution]: + """Find a WS execution that matches criteria and is after baseline. + + Returns the FIRST (oldest) matching execution after baseline, + not the last, to ensure we get the correct trade. + """ + matches = [] + all_executions = list(self.tester.ws.perp_executions.all) + + for execution in all_executions: + seq = execution.sequence_number or 0 + if seq <= self.baseline_seq: + continue + + if not self._matches_order(execution, expected_order, expected_qty): + # 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"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}" + ) + continue + + matches.append(execution) + + if not matches and all_executions: + # Log available executions for debugging + new_execs = [e for e in all_executions if (e.sequence_number or 0) > self.baseline_seq] + if new_execs: + logger.warning( + f"No matching execution found. {len(new_execs)} new executions after baseline={self.baseline_seq}: " + f"{[(e.sequence_number, e.symbol, _get_enum_value(e.side), e.qty) for e in new_execs[:5]]}" + ) + + if not matches: + return None + + # Return the FIRST match (lowest sequence number) - this is OUR trade + return min(matches, key=lambda e: e.sequence_number or 0) + + def _matches_order( + self, + execution: AsyncPerpExecution, + expected: Order, + expected_qty: Optional[str] = None, + ) -> bool: + """Check if execution matches expected order parameters.""" + if execution.account_id != expected.account_id: + return False + if execution.symbol != expected.symbol: + return False + if _get_enum_value(execution.side) != _get_enum_value(expected.side): + return False + + qty_to_match = expected_qty if expected_qty is not None else expected.qty + if not _compare_qty(execution.qty, qty_to_match): + return False + + return True + + async def wait_for_execution( + self, + expected_order: Order, + expected_qty: Optional[str] = None, + timeout: int = 10, + verify_position: bool = True, + ) -> TradeVerificationResult: + """Wait for trade execution and verify consistency across REST and WS. + + This method: + 1. Waits for a WS execution matching criteria with seq > baseline + 2. Uses that EXACT sequence number to fetch from REST + 3. Verifies both sources return the same trade + 4. Optionally verifies position updates + + Args: + expected_order: Order parameters to match against. + expected_qty: Optional qty override (for closing orders). + timeout: Maximum wait time in seconds. + verify_position: Whether to also verify position updates. + + Returns: + TradeVerificationResult with both REST and WS executions. + + Raises: + RuntimeError: If trade not found or verification fails. + """ + if not self._entered: + raise RuntimeError( + "PerpTradeContext must be used as async context manager. " + "Use: async with tester.perp_trade() as ctx: ..." + ) + + logger.info(f"⏳ Waiting for trade (baseline_seq={self.baseline_seq})...") + start_time = time.time() + + ws_execution: Optional[AsyncPerpExecution] = None + rest_execution: Optional[PerpExecution] = None + target_seq: Optional[int] = None + + while time.time() - start_time < timeout: + # Step 1: Find WS execution matching criteria + if ws_execution is None: + ws_execution = self._find_ws_execution(expected_order, expected_qty) + if ws_execution: + target_seq = ws_execution.sequence_number + elapsed = time.time() - start_time + logger.info(f" ✅ Trade found via WS: seq={target_seq} (took {elapsed:.2f}s)") + + # Step 2: Once we have WS execution, fetch EXACT same trade from REST + if ws_execution and rest_execution is None and target_seq: + rest_execution = await self._fetch_rest_execution_by_sequence(target_seq) + if rest_execution: + elapsed = time.time() - start_time + logger.info( + f" ✅ Trade confirmed via REST: seq={rest_execution.sequence_number} (took {elapsed:.2f}s)" + ) + + # Step 3: Verify consistency + if ws_execution and rest_execution: + if rest_execution.sequence_number != ws_execution.sequence_number: + raise AssertionError( + f"Trade sequence mismatch: REST={rest_execution.sequence_number}, " + f"WS={ws_execution.sequence_number}" + ) + + # Step 4: Optionally verify position + if verify_position and target_seq is not None: + position_verified = await self._verify_position( + expected_order.symbol, target_seq, timeout - (time.time() - start_time) + ) + if not position_verified: + logger.warning("Position verification timed out, but trade is confirmed") + + elapsed = time.time() - start_time + # At this point target_seq is guaranteed to be set since we have ws_execution + assert target_seq is not None, "target_seq should be set when ws_execution is found" + return TradeVerificationResult( + rest_execution=rest_execution, + ws_execution=ws_execution, + sequence_number=target_seq, + elapsed_time=elapsed, + ) + + await asyncio.sleep(0.1) + + # Timeout - provide detailed error + raise RuntimeError( + f"Trade not verified after {timeout}s. " + f"baseline_seq={self.baseline_seq}, " + f"ws_found={ws_execution is not None}, " + f"rest_found={rest_execution is not None}, " + f"target_seq={target_seq}" + ) + + async def wait_for_closing_execution( + self, + expected_order: Order, + expected_qty: Optional[str] = None, + timeout: int = 10, + ) -> TradeVerificationResult: + """Wait for position-closing trade execution. + + Similar to wait_for_execution but also verifies position is closed. + """ + result = await self.wait_for_execution( + expected_order=expected_order, + expected_qty=expected_qty, + timeout=timeout, + verify_position=False, # We'll do custom position verification + ) + + # Verify position is closed + start_time = time.time() + remaining_timeout = max(1, timeout - result.elapsed_time) + + while time.time() - start_time < remaining_timeout: + position = await self.tester.data.position(expected_order.symbol) + if position is None: + elapsed = time.time() - start_time + logger.info(f" ✅ Position closed: {expected_order.symbol} (took {elapsed:.2f}s)") + return result + await asyncio.sleep(0.1) + + logger.warning(f"Position not closed after {remaining_timeout}s, but trade is confirmed") + return result + + async def _verify_position( + self, + symbol: str, + expected_seq: int, + timeout: float, + ) -> bool: + """Verify position update matches the trade sequence.""" + start_time = time.time() + + while time.time() - start_time < timeout: + # Check WS position + ws_pos = self.tester.ws.positions.get(symbol) + if ws_pos and ws_pos.last_trade_sequence_number == expected_seq: + elapsed = time.time() - start_time + logger.info(f" ✅ Position confirmed via WS: {symbol} (took {elapsed:.2f}s)") + + # Also check REST position + rest_pos = await self.tester.data.position(symbol) + if rest_pos and rest_pos.last_trade_sequence_number == expected_seq: + logger.info(f" ✅ Position confirmed via REST: {symbol}") + return True + + await asyncio.sleep(0.1) + + return False diff --git a/tests/helpers/reya_tester/positions.py b/tests/helpers/reya_tester/positions.py new file mode 100644 index 00000000..38c29c9d --- /dev/null +++ b/tests/helpers/reya_tester/positions.py @@ -0,0 +1,198 @@ +"""Position operations for ReyaTester.""" + +from typing import TYPE_CHECKING + +import asyncio +import logging +import time + +from sdk.open_api.exceptions import ApiException +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 .utils import limit_order_params_to_order + +if TYPE_CHECKING: + from .tester import ReyaTester + +logger = logging.getLogger("reya.integration_tests") + + +class PositionOperations: + """Position management operations.""" + + def __init__(self, tester: "ReyaTester"): + self._t = tester + + async def close_all(self, fail_if_none: bool = True) -> None: + """Close all open positions.""" + 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: + logger.warning("No position to close") + if fail_if_none: + assert False + return None + + for symbol, _ in positions.items(): + # Re-fetch position to get current qty (may have changed due to trigger orders) + current_position = await self._t.data.position(symbol) + if current_position is None: + logger.info(f"Position {symbol} already closed, skipping") + continue + + price_with_offset = 0 if current_position.side == Side.B else 1000000000000 + + limit_order_params = LimitOrderParameters( + symbol=symbol, + is_buy=not (current_position.side == Side.B), + limit_px=str(price_with_offset), + 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 + + # Wait for positions to be actually closed + start_time = time.time() + timeout = 10 + + 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)") + return + + logger.debug(f"Still have {len(position_after)} positions, waiting...") + await asyncio.sleep(0.05) + + # Timeout reached + 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 + + async def setup( + self, + symbol: str = "ETHRUSDPERP", + is_buy: bool = True, + qty: str = "0.01", + price_multiplier: float = 1.01, + reduce_only: bool = False, + ) -> tuple[str, str]: + """ + Set up a position by creating and executing a limit order. + + Returns: + tuple[str, str]: (market_price, position_side) where position_side is 'B' for long, 'A' for short + """ + market_price = await self._t.data.current_price(symbol) + logger.info(f"Setting up {'long' if is_buy else 'short'} position at market price: ${market_price}") + + limit_order_params = LimitOrderParameters( + symbol=symbol, + limit_px=str(float(market_price) * price_multiplier), + is_buy=is_buy, + time_in_force=TimeInForce.IOC, + qty=qty, + reduce_only=reduce_only, + ) + await self._t.orders.create_limit(limit_order_params) + + account_id = self._t.account_id + if account_id is None: + raise ValueError("account_id is required for position setup") + expected_order = limit_order_params_to_order(limit_order_params, account_id) + await self._t.wait.for_order_execution(expected_order) + await self._t.check.no_open_orders() + + await asyncio.sleep(0.05) + + expected_side = Side.B if is_buy else Side.A + await self._t.check.position( + symbol=symbol, + expected_exchange_id=REYA_DEX_ID, + expected_account_id=account_id, + expected_qty=qty, + expected_side=expected_side, + ) + + return market_price, expected_side.value + + async def close(self, symbol: str, qty: str = "0.01") -> None: + """Manually close a position using a market order.""" + position = await self._t.data.position(symbol) + if position is None: + raise RuntimeError("No position found to close") + + is_buy = position.side == Side.A + + close_order_params = LimitOrderParameters( + symbol=symbol, + limit_px="0", + is_buy=is_buy, + time_in_force=TimeInForce.IOC, + qty=qty, + reduce_only=True, + ) + await self._t.orders.create_limit(close_order_params) + + account_id = self._t.account_id + if account_id is None: + raise ValueError("account_id is required for position close") + expected_order = limit_order_params_to_order(close_order_params, account_id) + execution = await self._t.wait.for_closing_order_execution(expected_order) + await self._t.check.order_execution(execution, expected_order, qty) + await self._t.check.position_not_open(symbol) + + async def flip(self, symbol: str, current_qty: str = "0.01", flip_qty: str = "0.02") -> None: + """Flip a position from long to short or vice versa.""" + position = await self._t.data.position(symbol) + if position is None: + raise RuntimeError("No position found to flip") + + is_buy = position.side == Side.A + + flip_order_params = LimitOrderParameters( + symbol=symbol, + limit_px="0", + is_buy=is_buy, + time_in_force=TimeInForce.IOC, + qty=flip_qty, + reduce_only=False, + ) + await self._t.orders.create_limit(flip_order_params) + + account_id = self._t.account_id + if account_id is None: + raise ValueError("account_id is required for position flip") + expected_order = limit_order_params_to_order(flip_order_params, account_id) + execution = await self._t.wait.for_order_execution(expected_order) + await self._t.check.order_execution(execution, expected_order, flip_qty) + + await asyncio.sleep(0.1) + + expected_side = Side.B if is_buy else Side.A + remaining_qty = str(float(flip_qty) - float(current_qty)) + + await self._t.check.position( + symbol=symbol, + expected_exchange_id=REYA_DEX_ID, + expected_account_id=account_id, + expected_qty=remaining_qty, + expected_side=expected_side, + ) diff --git a/tests/helpers/reya_tester/retry.py b/tests/helpers/reya_tester/retry.py new file mode 100644 index 00000000..83f640b2 --- /dev/null +++ b/tests/helpers/reya_tester/retry.py @@ -0,0 +1,81 @@ +"""Retry utilities for handling transient API errors.""" + +from typing import Callable, TypeVar + +import asyncio +import logging +from collections.abc import Awaitable + +from sdk.open_api.exceptions import ApiException, ServiceException + +logger = logging.getLogger("reya.integration_tests") + +T = TypeVar("T") + + +async def with_retry( + operation: Callable[[], Awaitable[T]], + max_retries: int = 3, + retry_delay: float = 1.0, + operation_name: str = "operation", +) -> T: + """Execute an async operation with retry logic for transient errors. + + Retries on: + - 502 Bad Gateway + - 503 Service Unavailable + - 504 Gateway Timeout + - SignatureExpired errors + - fetch failed errors + + Args: + operation: Async callable to execute + max_retries: Maximum number of retry attempts + retry_delay: Delay between retries in seconds + operation_name: Name for logging purposes + + Returns: + Result of the operation + + Raises: + The last exception if all retries fail + """ + last_exception = None + + for attempt in range(max_retries + 1): + try: + return await operation() + except (ServiceException, ApiException) as e: + last_exception = e + + # Check if this is a retryable error + is_retryable = False + error_msg = str(e) + + # Check for transient HTTP errors + if isinstance(e, ServiceException): + if hasattr(e, "status") and e.status in [502, 503, 504]: + is_retryable = True + + # Check for specific error messages + if "SignatureExpired" in error_msg: + is_retryable = True + if "fetch failed" in error_msg: + is_retryable = True + if "Bad Gateway" in error_msg: + is_retryable = True + + if is_retryable and attempt < max_retries: + logger.warning( + f"⚠️ {operation_name} failed (attempt {attempt + 1}/{max_retries + 1}): {error_msg[:100]}... Retrying in {retry_delay}s" + ) + await asyncio.sleep(retry_delay) + continue + + # Not retryable or out of retries + raise + + # Should not reach here, but just in case + if last_exception: + raise last_exception + raise RuntimeError(f"{operation_name} failed after {max_retries + 1} attempts") diff --git a/tests/helpers/reya_tester/store.py b/tests/helpers/reya_tester/store.py new file mode 100644 index 00000000..b7937270 --- /dev/null +++ b/tests/helpers/reya_tester/store.py @@ -0,0 +1,190 @@ +"""Generic state store for WebSocket and REST event tracking. + +This module provides a unified, type-safe storage pattern for all execution +and event types (perp, spot, orders, positions, balances). +""" + +from typing import Callable, Generic, Optional, TypeVar, Union, overload + +from collections.abc import Iterator, KeysView + +T = TypeVar("T") + + +class EventStore(Generic[T]): + """Generic store for any event/execution type. + + Provides both list-based storage (for searching) and optional + key-based access (for direct lookups by ID). + + Type Parameters: + T: The type of items stored (e.g., PerpExecution, SpotExecution). + + Example: + # Store with key-based access + orders = EventStore[Order](key_fn=lambda o: o.order_id) + orders.add(order) + order = orders.get("order-123") + + # Store with list-only access + executions = EventStore[PerpExecution]() + executions.add(execution) + match = executions.find(lambda e: e.symbol == "ETH") + """ + + def __init__(self, key_fn: Optional[Callable[[T], str]] = None) -> None: + """Initialize the store. + + Args: + key_fn: Optional function to extract a unique key from items. + If provided, enables get() for direct key-based lookups. + """ + self._items: list[T] = [] + self._by_key: dict[str, T] = {} + self._key_fn = key_fn + + def add(self, item: T) -> None: + """Add an item to the store. + + Args: + item: The item to add. + """ + self._items.append(item) + if self._key_fn is not None: + key = self._key_fn(item) + self._by_key[key] = item + + def find(self, predicate: Callable[[T], bool]) -> Optional[T]: + """Find the first item matching a predicate. + + Args: + predicate: Function that returns True for matching items. + + Returns: + The first matching item, or None if not found. + """ + return next((item for item in self._items if predicate(item)), None) + + def find_last(self, predicate: Callable[[T], bool]) -> Optional[T]: + """Find the last (most recent) item matching a predicate. + + Args: + predicate: Function that returns True for matching items. + + Returns: + The last matching item, or None if not found. + """ + return next((item for item in reversed(self._items) if predicate(item)), None) + + def find_all(self, predicate: Callable[[T], bool]) -> list[T]: + """Find all items matching a predicate. + + Args: + predicate: Function that returns True for matching items. + + Returns: + List of all matching items (may be empty). + """ + return [item for item in self._items if predicate(item)] + + def get(self, key: str) -> Optional[T]: + """Get an item by its key (requires key_fn in constructor). + + Args: + key: The key to look up. + + Returns: + The item with that key, or None if not found. + + Raises: + RuntimeError: If store was created without a key_fn. + """ + if self._key_fn is None: + raise RuntimeError("Cannot use get() on a store without key_fn") + return self._by_key.get(key) + + def contains_key(self, key: str) -> bool: + """Check if a key exists in the store. + + Args: + key: The key to check. + + Returns: + True if the key exists, False otherwise. + """ + if self._key_fn is None: + raise RuntimeError("Cannot use contains_key() on a store without key_fn") + return key in self._by_key + + def clear(self) -> None: + """Remove all items from the store.""" + self._items.clear() + self._by_key.clear() + + @property + def last(self) -> Optional[T]: + """Get the most recently added item. + + Returns: + The last item added, or None if store is empty. + """ + return self._items[-1] if self._items else None + + @property + def all(self) -> list[T]: + """Get a copy of all items in the store. + + Returns: + List of all items (copy, not reference). + """ + return self._items.copy() + + def __len__(self) -> int: + """Return the number of items in the store.""" + return len(self._items) + + def __iter__(self) -> Iterator[T]: + """Iterate over all items in the store.""" + return iter(self._items) + + def __bool__(self) -> bool: + """Return True if store has items, False if empty.""" + return len(self._items) > 0 + + def __contains__(self, key: str) -> bool: + """Check if a key exists (dict-like 'in' operator support).""" + if self._key_fn is None: + raise RuntimeError("Cannot use 'in' operator on a store without key_fn") + return key in self._by_key + + def keys(self) -> KeysView[str]: + """Return keys view (dict-like compatibility).""" + if self._key_fn is None: + raise RuntimeError("Cannot use keys() on a store without key_fn") + return self._by_key.keys() + + @overload + def __getitem__(self, key: int) -> T: ... # noqa: E704 + + @overload + def __getitem__(self, key: slice) -> list[T]: ... # noqa: E704 + + @overload + def __getitem__(self, key: str) -> T: ... # noqa: E704 + + def __getitem__(self, key: Union[int, slice, str]) -> Union[T, list[T]]: + """Get item by index, slice, or key. + + Supports: + - Integer index: store[0], store[-1] + - Slice: store[1:3], store[:-1] + - String key: store["order-123"] (requires key_fn) + """ + if isinstance(key, int): + return self._items[key] + elif isinstance(key, slice): + return self._items[key] + else: + if self._key_fn is None: + raise RuntimeError("Cannot use string key on a store without key_fn") + return self._by_key[key] diff --git a/tests/helpers/reya_tester/tester.py b/tests/helpers/reya_tester/tester.py new file mode 100644 index 00000000..71eccefe --- /dev/null +++ b/tests/helpers/reya_tester/tester.py @@ -0,0 +1,249 @@ +"""Main ReyaTester class with composition-based architecture.""" + +from typing import Optional + +import asyncio +import logging +import os + +from dotenv import load_dotenv + +from sdk.reya_rest_api import ReyaTradingClient +from sdk.reya_rest_api.config import TradingConfig +from sdk.reya_websocket import ReyaSocket + +from .checks import Checks +from .data import DataOperations +from .orders import OrderOperations +from .perp_trade_context import PerpTradeContext +from .positions import PositionOperations +from .waiters import Waiters +from .websocket import WebSocketState + +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") +logger = logging.getLogger("reya.integration_tests") + + +class ReyaTester: + """ + Integration test helper with composition-based architecture. + + Usage: + tester = ReyaTester() + await tester.setup() + + # Data operations + price = await tester.data.current_price("ETHRUSDPERP") + positions = await tester.data.positions() + + # Order operations + order_id = await tester.orders.create_limit(params) + await tester.orders.close_all() + + # Position operations + await tester.positions.setup(symbol="ETHRUSDPERP", is_buy=True) + await tester.positions.close(symbol) + + # Wait operations + await tester.wait.for_order_state(order_id, OrderStatus.FILLED) + await tester.wait.for_spot_execution(expected_order) + + # Check/assertion operations + await tester.check.no_open_orders() + await tester.check.position(symbol, expected_side=Side.B, ...) + tester.check.ws_order_change_received(order_id, ...) + + # WebSocket state + tester.ws.order_changes # dict of order changes + tester.ws.last_spot_execution # last spot execution + tester.ws.get_balance_update_count() + """ + + def __init__(self, spot_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. + """ + load_dotenv() + + # 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 + + if spot_account_number is None: + # Default - use standard config (PERP_ACCOUNT_ID_1, PERP_PRIVATE_KEY_1, PERP_WALLET_ADDRESS_1) + 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) + else: + raise ValueError(f"Invalid spot_account_number: {spot_account_number}. Must be None, 1, or 2.") + + # Store account properties - these must be set for tests to work + assert self.client is not None, "Client must be initialized" + assert self.client.config.account_id is not None, "account_id must be configured" + assert self.client.config.chain_id is not None, "chain_id must be configured" + + self.owner_wallet_address: Optional[str] = self.client.owner_wallet_address + self.account_id: int = self.client.config.account_id + self.chain_id: int = self.client.config.chain_id + + # Internal WebSocket reference + self._websocket: Optional[ReyaSocket] = None + + # Nonce tracking for order operations + self._nonce_counter: int = 0 + + # Composed components + self.ws = WebSocketState(self) + self.data = DataOperations(self) + self.orders = OrderOperations(self) + self.positions = PositionOperations(self) + self.wait = Waiters(self) + self.check = Checks(self) + + 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}") + + 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}" + ) + # 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") + if account_id is None: + raise ValueError("account_id is required for spot account") + spot_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), + ) + + # Create client with the spot config directly + return ReyaTradingClient(config=spot_config) + + async def setup(self) -> None: + """Set up WebSocket connection for trade monitoring.""" + ws_url = os.environ.get("REYA_WS_URL", "wss://ws.reya.xyz/") + await self.client.start() + + self._websocket = ReyaSocket( + url=ws_url, + on_open=self.ws.on_open, + on_message=self.ws.on_message, + ) + + self._websocket.connect() + logger.info("WebSocket connected for trade monitoring") + + await asyncio.sleep(0.05) + + # Only close perp orders/positions for perp accounts + # Spot accounts (account_id >= 10 billion) cannot trade perps + if not self._is_spot_account: + await self.orders.close_all(fail_if_none=False) + await self.positions.close_all(fail_if_none=False) + else: + logger.info("Skipping perp cleanup for spot account") + + async def close(self) -> None: + """Close all connections.""" + if self._websocket: + self._websocket.close() + await self.client.close() + + @property + def websocket(self) -> Optional[ReyaSocket]: + """Get the WebSocket connection (for cleanup/testing purposes).""" + return self._websocket + + @websocket.setter + def websocket(self, value: Optional[ReyaSocket]) -> None: + """Set the WebSocket connection (for reconnection in tests).""" + self._websocket = value + + @property + 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 is_spot_account(self) -> bool: + """Check if this tester is configured for a spot account.""" + return self._is_spot_account + + def perp_trade(self) -> PerpTradeContext: + """Create a context for bulletproof PERP trade verification. + + This context manager captures the baseline sequence number BEFORE + any order is placed, enabling reliable trade verification across + both REST and WebSocket channels. + + Usage: + async with reya_tester.perp_trade() as ctx: + await reya_tester.orders.create_limit(params) + result = await ctx.wait_for_execution(expected_order) + # result.rest_execution and result.ws_execution are guaranteed + # to be the SAME trade (same sequence_number) + + Returns: + PerpTradeContext: Context manager for trade verification. + """ + return PerpTradeContext(tester=self) + + def get_next_nonce(self) -> int: + """ + Get the next nonce from the SDK's nonce tracking mechanism. + + This is useful for validation tests that need to manually construct + API requests while keeping the nonce counter in sync. + + Returns: + The next nonce value to use for API requests. + """ + return self.client.get_next_nonce() + + async def get_last_perp_execution_sequence_number(self) -> int: + """Get the sequence number of the last perp execution. + + Returns 0 if no executions exist. + """ + execution = await self.data.last_perp_execution() + return execution.sequence_number if execution and execution.sequence_number else 0 + + async def get_last_wallet_perp_execution(self): + """Get the most recent perp execution for this wallet. + + Returns None if no executions exist. + """ + return await self.data.last_perp_execution() + + async def get_market_definition(self, symbol: str): + """Get market configuration for a specific symbol.""" + return await self.data.market_definition(symbol) + + async def wait_for_closing_order_execution(self, expected_order, expected_qty: Optional[str] = None): + """Wait for position-closing trade confirmation via both REST and WebSocket.""" + return await self.wait.for_closing_order_execution(expected_order, expected_qty) + + async def check_no_order_execution_since(self, since_sequence_number: int) -> None: + """Assert no order execution occurred since the given sequence number.""" + await self.check.no_order_execution_since(since_sequence_number) diff --git a/tests/helpers/reya_tester/utils.py b/tests/helpers/reya_tester/utils.py new file mode 100644 index 00000000..8df04570 --- /dev/null +++ b/tests/helpers/reya_tester/utils.py @@ -0,0 +1,49 @@ +"""Utility functions for ReyaTester.""" + +from sdk.open_api.models.order import Order +from sdk.open_api.models.order_status import OrderStatus +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.side import Side +from sdk.reya_rest_api.models import LimitOrderParameters, TriggerOrderParameters + + +def limit_order_params_to_order(params: LimitOrderParameters, account_id: int) -> Order: + """Convert LimitOrderParameters to Order object for testing""" + return Order( + exchangeId=5, # REYA_DEX_ID + symbol=params.symbol, + accountId=account_id, + orderId="", # Will be set when order is created + qty=params.qty, + execQty="0", + side=Side.B if params.is_buy else Side.A, + limitPx=params.limit_px, + orderType=OrderType.LIMIT, + triggerPx=None, + timeInForce=params.time_in_force, + reduceOnly=params.reduce_only or False, + status=OrderStatus.OPEN, + createdAt=0, + lastUpdateAt=0, + ) + + +def trigger_order_params_to_order(params: TriggerOrderParameters, account_id: int) -> Order: + """Convert TriggerOrderParameters to Order object for testing""" + return Order( + exchangeId=5, # REYA_DEX_ID + symbol=params.symbol, + accountId=account_id, + orderId="", # Will be set when order is created + qty=None, # Trigger orders don't have qty until execution + execQty="0", + side=Side.B if params.is_buy else Side.A, + limitPx=params.trigger_px, + orderType=params.trigger_type, + triggerPx=params.trigger_px, + timeInForce=None, + reduceOnly=False, + status=OrderStatus.OPEN, + createdAt=0, + lastUpdateAt=0, + ) diff --git a/tests/helpers/reya_tester/waiters.py b/tests/helpers/reya_tester/waiters.py new file mode 100644 index 00000000..674a67ec --- /dev/null +++ b/tests/helpers/reya_tester/waiters.py @@ -0,0 +1,469 @@ +"""Wait operations for ReyaTester. + +Uses unified EventStore and ExecutionMatcher for consistent state tracking. +""" + +from typing import TYPE_CHECKING, Optional, Union + +import asyncio +import logging +import time + +from sdk.async_api.order import Order as AsyncOrder +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_list import SpotExecutionList +from tests.helpers.validators import validate_order_fields, validate_spot_execution_fields + +from .matchers import ExecutionMatcher, FieldValidator + +if TYPE_CHECKING: + from .tester import ReyaTester + +logger = logging.getLogger("reya.integration_tests") + + +class Waiters: + """Wait operations for async events.""" + + def __init__(self, tester: "ReyaTester"): + self._t = tester + + async def _get_baseline_sequence(self) -> int: + """Get the current max sequence number to use as baseline for filtering. + + This prevents matching stale executions from previous operations. + """ + execution = await self._t.data.last_perp_execution() + return execution.sequence_number if execution and execution.sequence_number else 0 + + async def for_order_execution( + self, expected_order: Order, timeout: int = 10, baseline_seq: Optional[int] = None + ) -> PerpExecution: + """Wait for perp order execution confirmation via both REST and WebSocket. + + Args: + expected_order: The order to match against. + timeout: Maximum time to wait in seconds. + baseline_seq: Optional baseline sequence number. If provided, only executions + with sequence > baseline_seq will be matched. This should be + captured BEFORE placing the order to filter out stale executions. + """ + logger.info("⏳ Waiting for trade confirmation order...") + + start_time = time.time() + rest_trade = None + ws_trade = None + trade_seq_num = None + ws_position = None + rest_position = None + + # Use provided baseline or default to 0 (no filtering) + effective_baseline: int = baseline_seq if baseline_seq is not None else 0 + + while time.time() - start_time < timeout: + last_trade = await self._t.data.last_perp_execution() + + # Search through perp executions, filtering to only those AFTER baseline sequence + # This prevents matching stale executions from previous operations + if ws_trade is None: + + def match_after_baseline(e, bs: int = effective_baseline) -> bool: + return (e.sequence_number or 0) > bs and ExecutionMatcher.match_perp(e, expected_order) + + ws_trade = self._t.ws.perp_executions.find_last(match_after_baseline) + if ws_trade: + elapsed_time = time.time() - start_time + logger.info(f" ✅ Trade confirmed via WS: {ws_trade.sequence_number} (took {elapsed_time:.2f}s)") + trade_seq_num = ws_trade.sequence_number + + # Check WS position using EventStore.get() + ws_pos = self._t.ws.positions.get(expected_order.symbol) + if ( + ws_position is None + and ws_pos is not None + and trade_seq_num is not None + and trade_seq_num == ws_pos.last_trade_sequence_number + ): + ws_position = ws_pos + elapsed_time = time.time() - start_time + logger.info(f" ✅ Position confirmed via WS: {expected_order.symbol} (took {elapsed_time:.2f}s)") + + # Filter REST trade to only match if sequence > baseline + if ( + rest_trade is None + and last_trade is not None + and (last_trade.sequence_number or 0) > effective_baseline + and ExecutionMatcher.match_perp(last_trade, expected_order) + ): + elapsed_time = time.time() - start_time + logger.info(f" ✅ Trade confirmed via REST: {last_trade.sequence_number} (took {elapsed_time:.2f}s)") + rest_trade = last_trade + # Only set trade_seq_num from REST if WS hasn't set it yet + if trade_seq_num is None: + trade_seq_num = last_trade.sequence_number + + position = await self._t.data.position(expected_order.symbol) + if ( + rest_position is None + and position is not None + and trade_seq_num is not None + and trade_seq_num == position.last_trade_sequence_number + ): + elapsed_time = time.time() - start_time + logger.info(f" ✅ Position confirmed via REST: {expected_order.symbol} (took {elapsed_time:.2f}s)") + rest_position = position + + if rest_trade and ws_trade and rest_position and ws_position: + assert ( + rest_trade.sequence_number == ws_trade.sequence_number + ), f"Trade sequence mismatch: REST={rest_trade.sequence_number}, WS={ws_trade.sequence_number}" + assert ( + rest_position.symbol == ws_position.symbol + ), f"Position symbol mismatch: REST={rest_position.symbol}, WS={ws_position.symbol}" + return rest_trade + + await asyncio.sleep(0.1) + + raise RuntimeError( + f"Order not executed after {timeout} seconds, rest_trade: {rest_trade is not None}, " + f"ws_trade: {ws_trade is not None}, rest_position: {rest_position is not None}, ws_position: {ws_position is not None}" + ) + + async def for_closing_order_execution( + self, + expected_order: Order, + expected_qty: Optional[str] = None, + timeout: int = 10, + baseline_seq: Optional[int] = None, + ) -> PerpExecution: + """Wait for position-closing trade confirmation via both REST and WebSocket. + + Args: + expected_order: The order to match against. + expected_qty: Optional expected quantity for the closing trade. + timeout: Maximum time to wait in seconds. + baseline_seq: Optional baseline sequence number. If provided, only executions + with sequence > baseline_seq will be matched. This should be + captured BEFORE placing the order to filter out stale executions. + """ + logger.info("⏳ Waiting for position-closing trade confirmation...") + + start_time = time.time() + rest_trade = None + ws_trade = None + trade_seq_num = None + ws_position = None + rest_closed = False + + # Use provided baseline or default to 0 (no filtering) + effective_baseline: int = baseline_seq if baseline_seq is not None else 0 + + while time.time() - start_time < timeout: + last_trade = await self._t.data.last_perp_execution() + + # Search through perp executions, filtering to only those AFTER baseline sequence + # This prevents matching stale executions from previous operations + if ws_trade is None: + + def match_closing_after_baseline(e, bs: int = effective_baseline) -> bool: + return (e.sequence_number or 0) > bs and ExecutionMatcher.match_perp( + e, expected_order, expected_qty + ) + + ws_trade = self._t.ws.perp_executions.find_last(match_closing_after_baseline) + if ws_trade: + elapsed_time = time.time() - start_time + logger.info(f" ✅ Trade confirmed via WS: {ws_trade.sequence_number} (took {elapsed_time:.2f}s)") + trade_seq_num = ws_trade.sequence_number + + # Check WS position using EventStore.get() + ws_pos = self._t.ws.positions.get(expected_order.symbol) + if ( + ws_position is None + and ws_pos is not None + and trade_seq_num is not None + and trade_seq_num == ws_pos.last_trade_sequence_number + ): + ws_position = ws_pos + elapsed_time = time.time() - start_time + logger.info(f" ✅ Position confirmed via WS: {expected_order.symbol} (took {elapsed_time:.2f}s)") + + # Filter REST trade to only match if sequence > baseline + if ( + rest_trade is None + and last_trade is not None + and (last_trade.sequence_number or 0) > effective_baseline + and ExecutionMatcher.match_perp(last_trade, expected_order, expected_qty) + ): + elapsed_time = time.time() - start_time + logger.info(f" ✅ Trade confirmed via REST: {last_trade.sequence_number} (took {elapsed_time:.2f}s)") + rest_trade = last_trade + # Only set trade_seq_num from REST if WS hasn't set it yet + if trade_seq_num is None: + trade_seq_num = last_trade.sequence_number + + position = await self._t.data.position(expected_order.symbol) + if not rest_closed and position is None: + elapsed_time = time.time() - start_time + logger.info(f" ✅ Position closed via REST: {expected_order.symbol} (took {elapsed_time:.2f}s)") + rest_closed = True + + if rest_trade and ws_trade and rest_closed and ws_position: + assert ( + rest_trade.sequence_number == ws_trade.sequence_number + ), f"Trade sequence mismatch: REST={rest_trade.sequence_number}, WS={ws_trade.sequence_number}" + return rest_trade + + await asyncio.sleep(0.1) + + raise RuntimeError( + f"Order not executed after {timeout} seconds, rest_trade: {rest_trade is not None}, " + f"ws_trade: {ws_trade is not None}, rest_closed: {rest_closed}, ws_position: {ws_position is not None}" + ) + + async def for_spot_execution( + self, order_id: Optional[str], expected_order: Order, timeout: int = 10 + ) -> SpotExecution: + """Wait for spot execution confirmation via both REST and WebSocket. + + Performs strict matching on order_id and validates all important fields. + + Args: + order_id: The order ID to match (required, raises if None). + expected_order: The expected order to validate against. + timeout: Maximum time to wait in seconds. + """ + if order_id is None: + raise ValueError("order_id is required for reliable execution matching (got None)") + + logger.info(f"⏳ Waiting for spot execution (order_id={order_id})...") + + start_time = time.time() + rest_execution = None + ws_execution = None + + # Set order_id on expected_order for matching + expected_order.order_id = order_id + + while time.time() - start_time < timeout: + # Search through spot executions using EventStore.get() (keyed by order_id) + if ws_execution is None: + ws_exec = self._t.ws.spot_executions.get(str(order_id)) + if ws_exec: + elapsed_time = time.time() - start_time + + # Validate all fields match expected order + if ExecutionMatcher.match_spot(ws_exec, expected_order): + logger.info( + f" ✅ Spot execution confirmed via WS: order_id={order_id} (took {elapsed_time:.2f}s)" + ) + ws_execution = ws_exec + else: + # Log validation errors + validation_result = FieldValidator.validate_spot_execution(ws_exec, expected_order) + for error in validation_result.errors: + logger.warning(f" ⚠️ Validation: {error}") + ws_execution = ws_exec # Still use it, but warn + + if rest_execution is None and ws_execution is not None: + wallet_address = self._t.owner_wallet_address + if wallet_address is None: + raise ValueError("owner_wallet_address is required for spot execution lookup") + executions_list: SpotExecutionList = await self._t.client.wallet.get_wallet_spot_executions( + address=wallet_address + ) + for execution in executions_list.data: + if str(execution.order_id) == str(order_id): + elapsed_time = time.time() - start_time + logger.info( + f" ✅ Spot execution confirmed via REST: order_id={order_id} (took {elapsed_time:.2f}s)" + ) + rest_execution = execution + break + + if rest_execution and ws_execution: + # Validate spot execution fields structure + validate_spot_execution_fields(rest_execution, expected_symbol=expected_order.symbol) + logger.debug(f" ✅ Spot execution fields validated for order_id={order_id}") + return rest_execution + + await asyncio.sleep(0.1) + + raise RuntimeError( + f"Spot execution not confirmed after {timeout} seconds, " + f"order_id={order_id}, rest: {rest_execution is not None}, ws: {ws_execution is not None}" + ) + + async def for_order_state(self, order_id: Optional[str], expected_status: OrderStatus, timeout: int = 10) -> str: + """Wait for order to reach a specific state. + + Uses WS as the PRIMARY and AUTHORITATIVE source of truth for order status. + + NOTE: The REST API only has `getWalletOpenOrders` endpoint - there is NO + endpoint to get order history or a specific order's final status. Therefore: + - WS tells us the ACTUAL status (FILLED, CANCELLED, REJECTED) + - REST can only confirm "order is no longer open" (not the specific status) + + Returns the order_id if successful. + Raises RuntimeError if order reaches unexpected state or times out. + """ + if order_id is None: + raise ValueError("order_id is required for for_order_state (got None)") + logger.debug(f"⏳ Waiting for order {order_id} to reach state: {expected_status.value}...") + assert expected_status != OrderStatus.OPEN, "use for_order_creation instead" + + start_time = time.time() + ws_confirmed = False + rest_verified = False + + while time.time() - start_time < timeout: + # Step 1: WS is the PRIMARY source - wait for WS to show the status + ws_order = self._t.ws.orders.get(str(order_id)) + ws_status_value = ws_order.status.value if ws_order else None + + if ws_order and ws_status_value: + # Check if WS shows an unexpected terminal state + if ws_status_value != OrderStatus.OPEN.value and ws_status_value != expected_status.value: + raise RuntimeError( + f"Order {order_id} reached {ws_status_value} state via WS, but expected {expected_status.value}" + ) + + # Check if WS confirms the expected state + if not ws_confirmed and ws_status_value == expected_status.value: + elapsed_time = time.time() - start_time + logger.info( + f" ✅ Order reached {expected_status.value} state via WS: {order_id} (took {elapsed_time:.2f}s)" + ) + ws_confirmed = True + + # Step 2: Only verify with REST AFTER WS confirms + if ws_confirmed and not rest_verified: + orders: list[Order] = await self._t.client.get_open_orders() + order_ids = [order.order_id for order in orders] + + # For FILLED/CANCELLED/REJECTED, order should not be in open orders + if order_id not in order_ids: + elapsed_time = time.time() - start_time + logger.info( + f" ✅ Order verified not in open orders via REST: {order_id} (took {elapsed_time:.2f}s)" + ) + rest_verified = True + + if ws_confirmed and rest_verified: + return order_id + + await asyncio.sleep(0.1) + + # Timeout - provide detailed error with current state + ws_order = self._t.ws.orders.get(str(order_id)) + ws_status = ws_order.status.value if ws_order else "not found" + raise RuntimeError( + f"Order {order_id} did not reach {expected_status.value} state after {timeout}s. " + f"WS status: {ws_status}, WS confirmed: {ws_confirmed}, REST verified: {rest_verified}" + ) + + async def for_order_creation( + self, order_id: Optional[str], expected_order: Optional[Order] = None, timeout: int = 10 + ) -> Union[Order, AsyncOrder]: + """Wait for order creation confirmation via REST and WebSocket. + + Args: + order_id: The order ID to wait for (required, raises if None). + expected_order: If provided, validates WS order fields match expected values. + timeout: Maximum time to wait in seconds. + """ + if order_id is None: + raise ValueError("order_id is required for for_order_creation (got None)") + logger.debug(f"⏳ Waiting for order creation (order_id={order_id})...") + + start_time = time.time() + rest_order = None + ws_order = None + + while time.time() - start_time < timeout: + orders = await self._t.client.get_open_orders() + for order in orders: + if rest_order is None and order.order_id == order_id: + elapsed_time = time.time() - start_time + logger.info(f" ✅ Order created via REST: order_id={order_id} (took {elapsed_time:.2f}s)") + rest_order = order + break + + # Use EventStore.get() for keyed lookup + if ws_order is None: + ws_ord = self._t.ws.orders.get(str(order_id)) + if ws_ord: + ws_status = ws_ord.status.value if hasattr(ws_ord.status, "value") else ws_ord.status + if ws_status in ["OPEN", "PARTIALLY_FILLED"]: + elapsed_time = time.time() - start_time + logger.info( + f" ✅ Order created via WS: order_id={order_id}, status={ws_status} (took {elapsed_time:.2f}s)" + ) + ws_order = ws_ord + + # Validate fields if expected_order provided + if expected_order: + expected_order.order_id = order_id + validation_result = FieldValidator.validate_order_change(ws_order, expected_order) + if not validation_result.is_valid: + for error in validation_result.errors: + logger.warning(f" ⚠️ Validation: {error}") + + if rest_order and ws_order: + assert ( + rest_order.order_id == ws_order.order_id + ), f"Order ID mismatch: REST={rest_order.order_id}, WS={ws_order.order_id}" + # Validate order fields structure + validate_order_fields(rest_order, expected_symbol=rest_order.symbol) + logger.debug(f" ✅ Order fields validated for {order_id}") + return rest_order + elif ws_order: + elapsed_time = time.time() - start_time + logger.info(f" ✅ Order created via WS only: order_id={order_id} (took {elapsed_time:.2f}s)") + # Validate order fields structure + validate_order_fields(ws_order, expected_symbol=ws_order.symbol) + logger.debug(f" ✅ Order fields validated for {order_id}") + return ws_order + elif rest_order: + elapsed_time = time.time() - start_time + logger.info(f" ✅ Order created via REST only: order_id={order_id} (took {elapsed_time:.2f}s)") + # Validate order fields structure + validate_order_fields(rest_order, expected_symbol=rest_order.symbol) + logger.debug(f" ✅ Order fields validated for {order_id}") + return rest_order + + await asyncio.sleep(0.1) + + raise RuntimeError(f"Order {order_id} not created after {timeout} seconds") + + async def for_balance_updates( + self, + initial_count: int, + min_updates: int = 1, + timeout: float = 5.0, + ) -> int: + """Wait for balance update events via WebSocket. + + Args: + initial_count: The balance update count before the action. + min_updates: Minimum number of new updates expected. + timeout: Maximum time to wait in seconds. + + Returns: + The number of new balance updates received. + """ + start_time = time.time() + + while time.time() - start_time < timeout: + new_count = len(self._t.ws.balance_updates) - initial_count + if new_count >= min_updates: + elapsed_time = time.time() - start_time + logger.info(f" ✅ Received {new_count} balance update(s) via WS (took {elapsed_time:.2f}s)") + return new_count + await asyncio.sleep(0.05) + + 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") diff --git a/tests/helpers/reya_tester/websocket.py b/tests/helpers/reya_tester/websocket.py new file mode 100644 index 00000000..4c73ff7d --- /dev/null +++ b/tests/helpers/reya_tester/websocket.py @@ -0,0 +1,429 @@ +"""WebSocket state management for ReyaTester. + +This module uses async_api types directly from the WebSocket SDK. +These types match the AsyncAPI spec and are auto-generated. + +Uses EventStore for unified state tracking across all event types. +""" + +from typing import TYPE_CHECKING, Optional, Union + +import logging +from collections.abc import Mapping +from decimal import Decimal + +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.market_depth_update_payload import MarketDepthUpdatePayload +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 +from sdk.async_api.perp_execution import PerpExecution as AsyncPerpExecution +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.subscribed_message_payload import SubscribedMessagePayload +from sdk.async_api.wallet_perp_execution_update_payload import WalletPerpExecutionUpdatePayload +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 + +from .store import EventStore + +if TYPE_CHECKING: + from .tester import ReyaTester + +logger = logging.getLogger("reya.integration_tests") + + +class WebSocketState: + """WebSocket state tracking and management. + + Uses EventStore for unified state tracking across all event types. + All stores use the same pattern for consistency between perp/spot. + """ + + def __init__(self, tester: "ReyaTester"): + self._t = tester + + # Unified state tracking using EventStore + # Executions: list-based (search by predicate) + self.perp_executions: EventStore[AsyncPerpExecution] = EventStore() + self.spot_executions: EventStore[AsyncSpotExecution] = EventStore(key_fn=lambda x: str(x.order_id)) + self.balance_updates: EventStore[AsyncAccountBalance] = EventStore() + + # Keyed stores: direct lookup by key + self.positions: EventStore[AsyncPosition] = EventStore(key_fn=lambda x: x.symbol) + self.orders: EventStore[AsyncOrder] = EventStore(key_fn=lambda x: str(x.order_id)) + self.balances: EventStore[AsyncAccountBalance] = EventStore(key_fn=lambda x: x.asset) + + # Market-level stores (by symbol) + self.market_spot_executions: dict[str, EventStore[AsyncSpotExecution]] = {} + self.depth: dict[str, Depth] = {} + + # ========================================================================= + # Backward compatibility properties + # ========================================================================= + + @property + def order_changes(self) -> EventStore[AsyncOrder]: + """Backward compatibility: alias for orders store.""" + return self.orders + + @property + def last_trade(self) -> Optional[AsyncPerpExecution]: + """Backward compatibility: get last perp execution.""" + return self.perp_executions.last + + @last_trade.setter + def last_trade(self, value: Optional[AsyncPerpExecution]) -> None: + """Backward compatibility: setting last_trade clears and adds.""" + if value is None: + self.perp_executions.clear() + else: + self.perp_executions.add(value) + + @property + def last_spot_execution(self) -> Optional[AsyncSpotExecution]: + """Backward compatibility: get last spot execution.""" + return self.spot_executions.last + + @last_spot_execution.setter + def last_spot_execution(self, value: Optional[AsyncSpotExecution]) -> None: + """Backward compatibility: setting last_spot_execution clears and adds.""" + if value is None: + self.spot_executions.clear() + else: + self.spot_executions.add(value) + + @property + def last_depth(self) -> dict[str, Depth]: + """Backward compatibility: alias for depth.""" + return self.depth + + @property + def current_prices(self) -> dict: + """Backward compatibility: placeholder for current prices.""" + return {} + + def clear(self) -> None: + """Clear all WebSocket state.""" + self.perp_executions.clear() + self.spot_executions.clear() + self.balance_updates.clear() + self.positions.clear() + self.orders.clear() + self.balances.clear() + self.market_spot_executions.clear() + self.depth.clear() + + def clear_balance_updates(self) -> None: + """Clear the list of balance updates.""" + self.balance_updates.clear() + logger.debug("Cleared WebSocket balance updates") + + def clear_spot_executions(self) -> None: + """Clear the list of spot executions.""" + self.spot_executions.clear() + logger.debug("Cleared WebSocket spot executions") + + def clear_perp_executions(self) -> None: + """Clear the list of perp executions.""" + self.perp_executions.clear() + logger.debug("Cleared WebSocket perp executions") + + def get_balance_updates_for_account(self, account_id: int) -> list[AsyncAccountBalance]: + """Get all balance updates for a specific account.""" + return self.balance_updates.find_all(lambda b: b.account_id == account_id) + + def get_balance_update_count(self) -> int: + """Get the current count of balance updates.""" + return len(self.balance_updates) + + def subscribe_to_market_depth(self, symbol: str) -> None: + """Subscribe to L2 market depth updates for a specific symbol.""" + if self._t.websocket is None: + raise RuntimeError("WebSocket not connected - call setup() first") + self._t.websocket.market.depth(symbol).subscribe() + logger.info(f"Subscribed to market depth for {symbol}") + + def subscribe_to_market_spot_executions(self, symbol: str) -> None: + """Subscribe to market-level spot executions for a specific symbol.""" + if self._t.websocket is None: + raise RuntimeError("WebSocket not connected - call setup() first") + self._t.websocket.market.spot_executions(symbol).subscribe() + logger.info(f"Subscribed to market spot executions for {symbol}") + + def clear_market_spot_executions(self, symbol: Optional[str] = None) -> None: + """Clear market spot executions. If symbol provided, clear only that symbol.""" + if symbol: + if symbol in self.market_spot_executions: + self.market_spot_executions[symbol].clear() + logger.debug(f"Cleared market spot executions for {symbol}") + else: + self.market_spot_executions.clear() + logger.debug("Cleared all market spot executions") + + def on_open(self, ws) -> None: + """Handle WebSocket connection open.""" + logger.info("WebSocket opened, subscribing to trade feeds") + + ws.wallet.perp_executions(self._t.owner_wallet_address).subscribe() + ws.wallet.spot_executions(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() + + def on_message(self, _ws, message: WebSocketMessage) -> None: + """Handle WebSocket messages using typed payloads from SDK. + + The SDK parses all messages into typed Pydantic models automatically. + Uses EventStore for unified state tracking. + """ + logger.info(f"Received message: {type(message).__name__}") + + # Handle subscribed messages with initial snapshots + if isinstance(message, SubscribedMessagePayload): + self._handle_subscribed(message) + + # Handle perp executions + elif isinstance(message, WalletPerpExecutionUpdatePayload): + self._handle_perp_executions(message) + + # Handle spot executions (market or wallet level) + elif isinstance(message, (MarketSpotExecutionUpdatePayload, WalletSpotExecutionUpdatePayload)): + self._handle_spot_executions(message) + + # Handle order changes + elif isinstance(message, OrderChangeUpdatePayload): + self._handle_order_changes(message) + + # Handle position updates + elif isinstance(message, PositionUpdatePayload): + self._handle_position_updates(message) + + # Handle depth updates + elif isinstance(message, MarketDepthUpdatePayload): + self._handle_depth_update(message) + + # Handle account balance updates + elif isinstance(message, AccountBalanceUpdatePayload): + self._handle_balance_updates(message) + + def _handle_subscribed(self, message: SubscribedMessagePayload) -> None: + """Handle subscription confirmation with initial snapshot.""" + logger.info(f"✅ Subscribed to {message.channel}") + + # Handle initial snapshot for depth channel + if "depth" in message.channel and message.contents: + depth_data = Depth.model_validate(message.contents) + self.depth[depth_data.symbol] = depth_data + logger.info( + f"Stored depth snapshot for {depth_data.symbol}: {len(depth_data.bids)} bids, {len(depth_data.asks)} asks" + ) + + # Handle initial snapshot for market spot executions channel + if "/market/" in message.channel and "spotExecutions" in message.channel and message.contents: + symbol = message.channel.split("/")[3] # /v2/market/{symbol}/spotExecutions + data = message.contents.get("data", []) + + if symbol not in self.market_spot_executions: + self.market_spot_executions[symbol] = EventStore(key_fn=lambda x: str(x.order_id)) + + for e in data: + execution = AsyncSpotExecution.model_validate(e) + self.market_spot_executions[symbol].add(execution) + + logger.info(f"Stored market spot executions snapshot for {symbol}: {len(data)} execution(s)") + + # Handle initial snapshot for balances channel + if "balances" in message.channel and message.contents: + data = message.contents.get("data", []) + for b in data: + balance = AsyncAccountBalance.model_validate(b) + self.balances.add(balance) + logger.info(f"Stored balances snapshot: {len(data)} balance(s)") + + def _handle_perp_executions(self, message: WalletPerpExecutionUpdatePayload) -> None: + """Handle perp execution updates.""" + 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"side={trade.side.value if hasattr(trade.side, 'value') else trade.side}, qty={trade.qty}" + ) + self.perp_executions.add(trade) + + def _handle_spot_executions( + self, message: MarketSpotExecutionUpdatePayload | WalletSpotExecutionUpdatePayload + ) -> None: + """Handle spot execution updates (market or wallet level).""" + is_market_channel = "/market/" in message.channel + + for exec_data in message.data: + if is_market_channel: + symbol = message.channel.split("/")[3] + if symbol not in self.market_spot_executions: + self.market_spot_executions[symbol] = EventStore(key_fn=lambda x: str(x.order_id)) + self.market_spot_executions[symbol].add(exec_data) + logger.debug(f"Added market spot execution for {symbol}: {exec_data.order_id}") + else: + self.spot_executions.add(exec_data) + + def _handle_order_changes(self, message: OrderChangeUpdatePayload) -> None: + """Handle order change updates.""" + for order_data in message.data: + status_val = order_data.status.value if hasattr(order_data.status, "value") else order_data.status + logger.info( + f"📋 Order change: order_id={order_data.order_id}, status={status_val}, type={order_data.order_type.value if hasattr(order_data.order_type, 'value') else order_data.order_type}" + ) + self.orders.add(order_data) + + def _handle_position_updates(self, message: PositionUpdatePayload) -> None: + """Handle position updates.""" + for pos_data in message.data: + self.positions.add(pos_data) + + def _handle_depth_update(self, message: MarketDepthUpdatePayload) -> None: + """Handle depth updates with incremental merge.""" + new_depth = message.data + symbol = new_depth.symbol + existing = self.depth.get(symbol) + + if existing is None: + self.depth[symbol] = new_depth + return + + # Merge updates into existing depth + existing_bids = list(existing.bids) if existing.bids else [] + existing_asks = list(existing.asks) if existing.asks else [] + + # Process bid updates + for level in new_depth.bids: + existing_bids = [b for b in existing_bids if b.px != level.px] + if float(level.qty) > 0: + existing_bids.append(level) + + # Process ask updates + for level in new_depth.asks: + existing_asks = [a for a in existing_asks if a.px != level.px] + if float(level.qty) > 0: + existing_asks.append(level) + + # Sort bids descending, asks ascending + existing_bids = sorted(existing_bids, key=lambda x: float(x.px), reverse=True) + existing_asks = sorted(existing_asks, key=lambda x: float(x.px)) + + self.depth[symbol] = Depth( + symbol=symbol, + type=existing.type, + bids=existing_bids, + asks=existing_asks, + updatedAt=new_depth.updated_at, + ) + + def _handle_balance_updates(self, message: AccountBalanceUpdatePayload) -> None: + """Handle account balance updates.""" + for balance_data in message.data: + self.balances.add(balance_data) + self.balance_updates.add(balance_data) + logger.debug(f"Added balance update: account_id={balance_data.account_id}, asset={balance_data.asset}") + + 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]], + base_asset: str, + quote_asset: str, + qty: str, + price: str, + is_maker_buyer: bool, + ) -> None: + """ + Verify that balance changes for a spot trade match expected amounts EXACTLY. + + Note: Spot trading has ZERO fees, so we verify exact balance changes: + - Base asset change = qty (exactly) + - Quote asset change = qty * price (exactly) + """ + qty_decimal = Decimal(qty) + price_decimal = Decimal(price) + notional = qty_decimal * price_decimal + + logger.info("\n💰 Verifying EXACT spot trade balance changes (zero fees)...") + logger.info(f"Trade: qty={qty} {base_asset} at price={price} {quote_asset}") + logger.info(f"Notional: {notional} {quote_asset}") + logger.info(f"Maker is {'BUYER' if is_maker_buyer else 'SELLER'}") + + maker_initial_base = maker_initial_balances.get(base_asset) + maker_final_base = maker_final_balances.get(base_asset) + maker_initial_quote = maker_initial_balances.get(quote_asset) + maker_final_quote = maker_final_balances.get(quote_asset) + + taker_initial_base = taker_initial_balances.get(base_asset) + taker_final_base = taker_final_balances.get(base_asset) + taker_initial_quote = taker_initial_balances.get(quote_asset) + taker_final_quote = taker_final_balances.get(quote_asset) + + logger.info(f"Maker balances - initial: base={maker_initial_base}, quote={maker_initial_quote}") + logger.info(f"Maker balances - final: base={maker_final_base}, quote={maker_final_quote}") + logger.info(f"Taker balances - initial: base={taker_initial_base}, quote={taker_initial_quote}") + logger.info(f"Taker balances - final: base={taker_final_base}, quote={taker_final_quote}") + + assert maker_initial_base and maker_final_base, f"Maker {base_asset} balance not found" + assert maker_initial_quote and maker_final_quote, f"Maker {quote_asset} balance not found" + assert taker_initial_base and taker_final_base, f"Taker {base_asset} balance not found" + assert taker_initial_quote and taker_final_quote, f"Taker {quote_asset} balance not found" + + # Use Decimal for exact comparison (spot has zero fees) + maker_base_change = Decimal(maker_final_base.real_balance) - Decimal(maker_initial_base.real_balance) + maker_quote_change = Decimal(maker_final_quote.real_balance) - Decimal(maker_initial_quote.real_balance) + taker_base_change = Decimal(taker_final_base.real_balance) - Decimal(taker_initial_base.real_balance) + taker_quote_change = Decimal(taker_final_quote.real_balance) - Decimal(taker_initial_quote.real_balance) + + logger.info( + f"Maker {base_asset} change: {maker_base_change} (expected: {'+' if is_maker_buyer else '-'}{qty_decimal})" + ) + logger.info( + f"Maker {quote_asset} change: {maker_quote_change} (expected: {'-' if is_maker_buyer else '+'}{notional})" + ) + logger.info( + f"Taker {base_asset} change: {taker_base_change} (expected: {'-' if is_maker_buyer else '+'}{qty_decimal})" + ) + logger.info( + f"Taker {quote_asset} change: {taker_quote_change} (expected: {'+' if is_maker_buyer else '-'}{notional})" + ) + + # Calculate expected EXACT changes (zero fees) + if is_maker_buyer: + expected_maker_base_change = qty_decimal + expected_maker_quote_change = -notional + expected_taker_base_change = -qty_decimal + expected_taker_quote_change = notional + else: + expected_maker_base_change = -qty_decimal + expected_maker_quote_change = notional + expected_taker_base_change = qty_decimal + expected_taker_quote_change = -notional + + # Verify EXACT match (spot has zero fees) + assert ( + maker_base_change == expected_maker_base_change + ), f"Maker {base_asset} change {maker_base_change} does not exactly match expected {expected_maker_base_change}" + + assert ( + maker_quote_change == expected_maker_quote_change + ), f"Maker {quote_asset} change {maker_quote_change} does not exactly match expected {expected_maker_quote_change}" + + assert ( + taker_base_change == expected_taker_base_change + ), f"Taker {base_asset} change {taker_base_change} does not exactly match expected {expected_taker_base_change}" + + assert ( + taker_quote_change == expected_taker_quote_change + ), f"Taker {quote_asset} change {taker_quote_change} does not exactly match expected {expected_taker_quote_change}" + + logger.info("✅ All balance changes verified EXACTLY (zero fees confirmed)!") diff --git a/tests/helpers/validators.py b/tests/helpers/validators.py new file mode 100644 index 00000000..07682e80 --- /dev/null +++ b/tests/helpers/validators.py @@ -0,0 +1,237 @@ +""" +Shared validation functions for order and execution response fields. + +These validators ensure API responses have correct structure and data types. +They are automatically called by wait.for_order_creation() and wait.for_spot_execution(). +""" + +from typing import Optional, Union + +import logging +from decimal import Decimal, InvalidOperation + +from sdk.async_api.order import Order as AsyncOrder +from sdk.async_api.spot_execution import SpotExecution as AsyncSpotExecution +from sdk.open_api.models.order import Order +from sdk.open_api.models.spot_execution import SpotExecution + +logger = logging.getLogger("reya.integration_tests") + + +def _is_numeric_string(value: str) -> bool: + """Check if a string represents a valid numeric value.""" + try: + Decimal(value) + return True + except (InvalidOperation, ValueError): + return False + + +def validate_order_fields( + order: Union[Order, AsyncOrder], + expected_symbol: Optional[str] = None, + is_gtc: bool = True, + log_details: bool = False, +) -> None: + """ + Validate all required order fields have correct types and values. + + Args: + order: The order to validate (REST or WS order model) + expected_symbol: Expected symbol to match (optional) + is_gtc: Whether this is a GTC order (affects time_in_force validation) + log_details: Whether to log each validated field + + Raises: + AssertionError: If any field validation fails + """ + # exchange_id + assert hasattr(order, "exchange_id"), "Order should have 'exchange_id'" + assert isinstance(order.exchange_id, int), f"exchange_id should be int, got {type(order.exchange_id)}" + if log_details: + logger.info(f"✅ exchange_id: {order.exchange_id}") + + # symbol + assert hasattr(order, "symbol"), "Order should have 'symbol'" + assert isinstance(order.symbol, str), f"symbol should be str, got {type(order.symbol)}" + if expected_symbol: + assert order.symbol == expected_symbol, f"Expected symbol {expected_symbol}, got {order.symbol}" + if log_details: + logger.info(f"✅ symbol: {order.symbol}") + + # account_id + assert hasattr(order, "account_id"), "Order should have 'account_id'" + assert isinstance(order.account_id, int), f"account_id should be int, got {type(order.account_id)}" + if log_details: + logger.info(f"✅ account_id: {order.account_id}") + + # order_id + assert hasattr(order, "order_id"), "Order should have 'order_id'" + assert isinstance(order.order_id, str), f"order_id should be str, got {type(order.order_id)}" + assert len(order.order_id) > 0, "order_id should not be empty" + if log_details: + logger.info(f"✅ order_id: {order.order_id}") + + # qty (can be None for trigger orders) + assert hasattr(order, "qty"), "Order should have 'qty'" + if order.qty is not None: + assert isinstance(order.qty, str), f"qty should be str, got {type(order.qty)}" + assert _is_numeric_string(order.qty), f"qty should be numeric: {order.qty}" + if log_details: + logger.info(f"✅ qty: {order.qty}") + + # side + assert hasattr(order, "side"), "Order should have 'side'" + assert order.side is not None, "side should not be None" + assert hasattr(order.side, "value"), f"side should be an enum, got {type(order.side)}" + if log_details: + logger.info(f"✅ side: {order.side}") + + # limit_px + assert hasattr(order, "limit_px"), "Order should have 'limit_px'" + if order.limit_px is not None: + assert isinstance(order.limit_px, str), f"limit_px should be str, got {type(order.limit_px)}" + assert _is_numeric_string(order.limit_px), f"limit_px should be numeric: {order.limit_px}" + if log_details: + logger.info(f"✅ limit_px: {order.limit_px}") + + # order_type + assert hasattr(order, "order_type"), "Order should have 'order_type'" + assert order.order_type is not None, "order_type should not be None" + assert hasattr(order.order_type, "value"), f"order_type should be an enum, got {type(order.order_type)}" + if log_details: + logger.info(f"✅ order_type: {order.order_type}") + + # time_in_force (for GTC orders) + if is_gtc: + assert hasattr(order, "time_in_force"), "Order should have 'time_in_force'" + if order.time_in_force is not None: + assert hasattr( + order.time_in_force, "value" + ), f"time_in_force should be an enum, got {type(order.time_in_force)}" + if log_details: + logger.info(f"✅ time_in_force: {order.time_in_force}") + + # status + assert hasattr(order, "status"), "Order should have 'status'" + assert order.status is not None, "status should not be None" + assert hasattr(order.status, "value"), f"status should be an enum, got {type(order.status)}" + if log_details: + logger.info(f"✅ status: {order.status}") + + # created_at + assert hasattr(order, "created_at"), "Order should have 'created_at'" + assert isinstance(order.created_at, int), f"created_at should be int, got {type(order.created_at)}" + assert order.created_at > 0, f"created_at should be positive timestamp, got {order.created_at}" + if log_details: + logger.info(f"✅ created_at: {order.created_at}") + + # last_update_at + assert hasattr(order, "last_update_at"), "Order should have 'last_update_at'" + assert isinstance(order.last_update_at, int), f"last_update_at should be int, got {type(order.last_update_at)}" + assert order.last_update_at > 0, f"last_update_at should be positive timestamp, got {order.last_update_at}" + if log_details: + logger.info(f"✅ last_update_at: {order.last_update_at}") + + +def validate_spot_execution_fields( + execution: Union[SpotExecution, AsyncSpotExecution], + expected_symbol: Optional[str] = None, + log_details: bool = False, +) -> None: + """ + Validate all required spot execution fields have correct types and values. + + Args: + execution: The spot execution to validate (REST or WS model) + expected_symbol: Expected symbol to match (optional) + log_details: Whether to log each validated field + + Raises: + AssertionError: If any field validation fails + """ + # exchange_id (optional) + if execution.exchange_id is not None: + assert isinstance(execution.exchange_id, int), f"exchange_id should be int, got {type(execution.exchange_id)}" + if log_details: + logger.info(f"✅ exchange_id: {execution.exchange_id}") + + # symbol + assert hasattr(execution, "symbol"), "Execution should have 'symbol'" + assert isinstance(execution.symbol, str), f"symbol should be str, got {type(execution.symbol)}" + if expected_symbol: + assert execution.symbol == expected_symbol, f"Expected symbol {expected_symbol}, got {execution.symbol}" + if log_details: + logger.info(f"✅ symbol: {execution.symbol}") + + # account_id + assert hasattr(execution, "account_id"), "Execution should have 'account_id'" + assert isinstance(execution.account_id, int), f"account_id should be int, got {type(execution.account_id)}" + if log_details: + logger.info(f"✅ account_id: {execution.account_id}") + + # maker_account_id + assert hasattr(execution, "maker_account_id"), "Execution should have 'maker_account_id'" + assert isinstance( + execution.maker_account_id, int + ), f"maker_account_id should be int, got {type(execution.maker_account_id)}" + if log_details: + logger.info(f"✅ maker_account_id: {execution.maker_account_id}") + + # order_id (optional) + if execution.order_id is not None: + assert isinstance(execution.order_id, str), f"order_id should be str, got {type(execution.order_id)}" + if log_details: + logger.info(f"✅ order_id: {execution.order_id}") + + # maker_order_id (optional) + if execution.maker_order_id is not None: + assert isinstance( + execution.maker_order_id, str + ), f"maker_order_id should be str, got {type(execution.maker_order_id)}" + if log_details: + logger.info(f"✅ maker_order_id: {execution.maker_order_id}") + + # side + assert hasattr(execution, "side"), "Execution should have 'side'" + assert execution.side is not None, "side should not be None" + assert hasattr(execution.side, "value"), f"side should be an enum, got {type(execution.side)}" + if log_details: + logger.info(f"✅ side: {execution.side}") + + # qty + assert hasattr(execution, "qty"), "Execution should have 'qty'" + assert isinstance(execution.qty, str), f"qty should be str, got {type(execution.qty)}" + assert _is_numeric_string(execution.qty), f"qty should be numeric: {execution.qty}" + assert Decimal(execution.qty) > 0, f"qty should be positive: {execution.qty}" + if log_details: + logger.info(f"✅ qty: {execution.qty}") + + # price + assert hasattr(execution, "price"), "Execution should have 'price'" + assert isinstance(execution.price, str), f"price should be str, got {type(execution.price)}" + assert _is_numeric_string(execution.price), f"price should be numeric: {execution.price}" + assert Decimal(execution.price) > 0, f"price should be positive: {execution.price}" + if log_details: + logger.info(f"✅ price: {execution.price}") + + # fee + assert hasattr(execution, "fee"), "Execution should have 'fee'" + assert isinstance(execution.fee, str), f"fee should be str, got {type(execution.fee)}" + assert _is_numeric_string(execution.fee), f"fee should be numeric: {execution.fee}" + if log_details: + logger.info(f"✅ fee: {execution.fee}") + + # type + assert hasattr(execution, "type"), "Execution should have 'type'" + assert execution.type is not None, "type should not be None" + assert hasattr(execution.type, "value"), f"type should be an enum, got {type(execution.type)}" + if log_details: + logger.info(f"✅ type: {execution.type}") + + # timestamp + assert hasattr(execution, "timestamp"), "Execution should have 'timestamp'" + assert isinstance(execution.timestamp, int), f"timestamp should be int, got {type(execution.timestamp)}" + assert execution.timestamp > 0, f"timestamp should be positive: {execution.timestamp}" + if log_details: + logger.info(f"✅ timestamp: {execution.timestamp}") diff --git a/tests/test_perps/__init__.py b/tests/test_perps/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_perps/test_limit_orders.py b/tests/test_perps/test_limit_orders.py new file mode 100644 index 00000000..a7bcf6db --- /dev/null +++ b/tests/test_perps/test_limit_orders.py @@ -0,0 +1,525 @@ +#!/usr/bin/env python3 + +from decimal import InvalidOperation + +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.models.side import Side +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 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") + + +@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, + ) + + logger.info("Trade confirmation task") + + await reya_tester.orders.create_limit(limit_order_params) + + # 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") + + +@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, + ) + 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, + ) + 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, + ) + + 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, + ) + + 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, + ) + + # 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, + ) + + 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 + ) + + if is_new_execution: + logger.info( + f"Order was filled (new sequence: {order_execution_details.sequence_number} > {last_sequence_before})" + ) + 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() + + +@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() + + 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") diff --git a/tests/test_perps/test_market_data.py b/tests/test_perps/test_market_data.py new file mode 100644 index 00000000..b26dfc4a --- /dev/null +++ b/tests/test_perps/test_market_data.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +import re +import time + +import pytest + +from sdk.open_api.exceptions import ServiceException +from tests.helpers import ReyaTester +from tests.helpers.reya_tester import logger + + +@pytest.mark.asyncio +async def test_market_definition(reya_tester: ReyaTester): + symbol = "ETHRUSDPERP" + market_definition = await reya_tester.get_market_definition(symbol) + assert market_definition is not None + assert market_definition.market_id == 1, "Wrong market id" + assert 0 < float(market_definition.min_order_qty) < 10**18, "Wrong min order qty" + assert 0 < float(market_definition.qty_step_size) < 10**18, "Wrong qty step size" + assert 0 < float(market_definition.tick_size) < 10**18, "Wrong tick size" + assert 0 < float(market_definition.liquidation_margin_parameter) < 10**18, "Wrong liquidation margin parameter" + assert 0 < float(market_definition.initial_margin_parameter) < 10**18, "Wrong initial margin parameter" + assert 0 < market_definition.max_leverage < 10**18, "Wrong max leverage" + assert 0 < float(market_definition.oi_cap) < 10**18, "Wrong oi cap" + assert market_definition.symbol == symbol + + +@pytest.mark.asyncio +async def test_market_definitions(reya_tester: ReyaTester): + market_definitions = await reya_tester.client.reference.get_market_definitions() + assert market_definitions is not None + assert len(market_definitions) > 0 + for market_definition in market_definitions: + assert re.match("^[a-zA-Z0-9]*$", market_definition.symbol), "Symbol should only have alphanumeric characters" + assert 0 < float(market_definition.min_order_qty) < 10**18, "Wrong min order qty" + assert 0 < float(market_definition.qty_step_size) < 10**18, "Wrong qty step size" + assert 0 < float(market_definition.tick_size) < 10**18, "Wrong tick size" + assert 0 < float(market_definition.liquidation_margin_parameter) < 10**18, "Wrong liquidation margin parameter" + assert 0 < float(market_definition.initial_margin_parameter) < 10**18, "Wrong initial margin parameter" + assert 0 < market_definition.max_leverage < 10**18, "Wrong max leverage" + assert 0 <= float(market_definition.oi_cap) < 10**18, "Wrong oi cap" + + +@pytest.mark.asyncio +async def test_market_price(reya_tester: ReyaTester): + symbol = "ETHRUSDPERP" + price = await reya_tester.client.markets.get_price(symbol) + 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 + current_time = int(time.time()) + assert price.updated_at / 1000 > current_time - 60 + + +@pytest.mark.asyncio +async def test_all_prices(reya_tester: ReyaTester): + prices = await reya_tester.client.markets.get_prices() + assert prices is not None + assert len(prices) > 0, "Should have at least one price" + + 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: + 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" + 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): + 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 -(10**3) < float(market_summary.funding_rate) < 10**3, "Funding rate should be a valid number" + + assert ( + market_summary.long_funding_value.replace(".", "", 1).lstrip("-").isdigit() + ), "Long funding value should be a valid number" + 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" + + 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" + + +@pytest.mark.asyncio +async def test_markets_summary(reya_tester: ReyaTester): + markets_summary = await reya_tester.client.markets.get_markets_summary() + assert markets_summary is not None + assert len(markets_summary) > 0 + + +@pytest.mark.asyncio +async def test_candles(reya_tester: ReyaTester): + symbol = "ETHRUSDPERP" + + 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" + else ( + 60 * 5 + if resolution == "5m" + else ( + 60 * 15 + if resolution == "15m" + else (60 * 60 if resolution == "1h" else 60 * 60 * 4 if resolution == "4h" else 60 * 60 * 24) + ) + ) + ) + current_time = int(time.time() * 1000) + candles = await reya_tester.client.markets.get_candles( + symbol=symbol, resolution=resolution, end_time=current_time + ) + assert candles is not None + assert len(candles.t) == candles_count + assert len(candles.c) == candles_count + assert len(candles.o) == candles_count + assert len(candles.h) == candles_count + assert len(candles.l) == candles_count + # Verify candles are in chronological order (timestamps should be increasing) + for t in range(1, candles_count): + assert ( + candles.t[t] > candles.t[t - 1] + ), f"Candle {t} timestamp {candles.t[t]} should be greater than previous {candles.t[t - 1]}" + # Gap should be a multiple of resolution (gaps can occur due to missing data) + gap = candles.t[t] - candles.t[t - 1] + assert ( + gap % resolution_in_seconds == 0 + ), f"Candle {t} gap {gap} should be a multiple of {resolution_in_seconds}" + + +@pytest.mark.asyncio +async def test_market_perp_executions(reya_tester: ReyaTester): + symbol = "ETHRUSDPERP" + executions = await reya_tester.client.markets.get_market_perp_executions(symbol) + assert executions is not None + assert len(executions.data) > 0 + + 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}" + assert execution.sequence_number > 0, "Sequence number should be positive" + assert execution.account_id > 0, "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" + assert execution.type in [ + "ORDER_MATCH", + "LIQUIDATION", + ], f"Unexpected execution type: {execution.type}" + + +@pytest.mark.asyncio +async def test_asset_definitions(reya_tester: ReyaTester): + try: + assets = await reya_tester.client.reference.get_asset_definitions() + except ServiceException as e: + # API may return 500 error - skip test if server is having issues + pytest.skip(f"API returned server error: {e.status}") + assert assets is not None + assert len(assets) > 0 + + tokens = {} + for asset in assets: + assert asset.asset is not None and len(asset.asset) > 0, "Asset symbol should not be empty" + assert re.match("^[a-zA-Z0-9]*$", asset.asset), "Asset symbol should only have alphanumeric characters" + + assert ( + 0 <= float(asset.price_haircut) <= 1 + ), f"Price haircut should be between 0 and 1, got: {asset.price_haircut}" + assert ( + 0 <= float(asset.liquidation_discount) <= 1 + ), f"Liquidation discount should be between 0 and 1, got: {asset.liquidation_discount}" + + # spotMarketSymbol is optional - some assets like RUSD don't have a spot market + if asset.spot_market_symbol is not None: + tokens[asset.spot_market_symbol] = asset + assert re.match( + "^[a-zA-Z0-9]*$", asset.spot_market_symbol + ), "Spot market symbol should only have alphanumeric characters" + assert ( + "USD" in asset.spot_market_symbol + ), f"Spot market symbol should contain USD, got: {asset.spot_market_symbol}" + + assert "WETHRUSD" in tokens, "WETHRUSD should be in asset definitions" + assert "SRUSDRUSD" in tokens, "SRUSDRUSD should be in asset definitions" + + weth_asset = tokens.get("WETHRUSD") + assert weth_asset is not None + assert weth_asset.asset == "ETH", f"Expected ETH asset, got: {weth_asset.asset}" + + +@pytest.mark.asyncio +async def test_fee_tier_parameters(reya_tester: ReyaTester): + fee_tiers = await reya_tester.client.reference.get_fee_tier_parameters() + assert fee_tiers is not None + assert len(fee_tiers) > 0 + # Check fee tier structure for common attributes + tiers = {} + for tier in fee_tiers: + tiers[tier.tier_id] = tier + assert 0 <= float(tier.maker_fee) <= 1 + assert 0 <= float(tier.taker_fee) <= 1 + assert tier.tier_type in ["REGULAR", "VIP"] + + assert len(tiers.keys()) > 0 + + +@pytest.mark.asyncio +async def test_global_fee_parameters(reya_tester: ReyaTester): + global_fees = await reya_tester.client.reference.get_global_fee_parameters() + assert global_fees is not None + assert 0 <= float(global_fees.og_discount) <= 1 + 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) <= 10000, 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 new file mode 100644 index 00000000..1adcc3ca --- /dev/null +++ b/tests/test_perps/test_position_management.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Tests for perp position management edge cases (increase, decrease, partial close).""" + +import pytest + +from sdk.open_api.models.side import Side +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api.config import REYA_DEX_ID +from sdk.reya_rest_api.models import LimitOrderParameters +from tests.helpers import ReyaTester +from tests.helpers.reya_tester import limit_order_params_to_order, logger + + +@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, + ) + + 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, + ) + + 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) + + expected_total_qty = str(float(initial_qty) + float(add_qty)) + await reya_tester.check.position( + symbol=symbol, + expected_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + expected_qty=expected_total_qty, + expected_side=Side.B, + ) + + 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, + ) + + 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, + ) + + 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, + ) + + 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) + + expected_total_qty = str(float(initial_qty) + float(add_qty)) + await reya_tester.check.position( + symbol=symbol, + expected_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + expected_qty=expected_total_qty, + 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() + initial_qty = "0.02" + + 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, + ) + + 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, + ) + + 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.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() + 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, + ) + + 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 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_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, + ) + + 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() + initial_qty = "0.02" + + 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 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_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + expected_qty=initial_qty, + 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) + + 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, + ) + + logger.info("✅ Position decrease without reduce_only test completed successfully") diff --git a/tests/test_perps/test_trigger_orders.py b/tests/test_perps/test_trigger_orders.py new file mode 100644 index 00000000..ca383e49 --- /dev/null +++ b/tests/test_perps/test_trigger_orders.py @@ -0,0 +1,787 @@ +#!/usr/bin/env python3 + +from typing import Optional + +import asyncio + +import pytest + +from sdk.open_api import RequestError, RequestErrorCode, TimeInForce +from sdk.open_api.exceptions import ApiException, BadRequestException +from sdk.open_api.models.create_order_response import CreateOrderResponse +from sdk.open_api.models.order import Order +from sdk.open_api.models.order_status import OrderStatus +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.position import Position +from sdk.open_api.models.side import Side +from sdk.reya_rest_api.config import REYA_DEX_ID +from sdk.reya_rest_api.models import LimitOrderParameters, TriggerOrderParameters +from tests.helpers import ReyaTester +from tests.helpers.reya_tester import limit_order_params_to_order, logger, trigger_order_params_to_order + + +def assert_tp_sl_order_submission( + order_details: Order, + expected_order_details: Order, + position: Position, + _reya_tester: ReyaTester, +): + """Assert that order execution details are correct""" + + assert order_details is not None, "Should have order execution details" + assert order_details.symbol == expected_order_details.symbol, "Market ID should match" + assert order_details.trigger_px is not None + assert order_details.limit_px is not None + assert expected_order_details.limit_px is not None + assert expected_order_details.trigger_px is not None + assert float(order_details.trigger_px) == pytest.approx( + float(expected_order_details.trigger_px), rel=1e-6 + ), "Trigger price should match" + assert float(order_details.limit_px) == pytest.approx( + float(expected_order_details.limit_px), rel=1e-6 + ), "Limit price should match" + assert order_details.side == expected_order_details.side, "Executed base should match" + assert expected_order_details.qty is not None + assert float(position.qty) == float(expected_order_details.qty), "Order direction does not match" + assert order_details.status == OrderStatus.OPEN, "Order status should be PENDING" + + logger.info("✅ Order submission confirmed correctly") + + +@pytest.mark.asyncio +async def test_success_tp_order_create_cancel(reya_tester: ReyaTester): + """TP order, close right after creation""" + symbol = "ETHRUSDPERP" + + # SETUP - capture sequence number BEFORE any actions + _ = await reya_tester.get_last_perp_execution_sequence_number() + + market_price = await reya_tester.data.current_price(symbol) + limit_order_params = LimitOrderParameters( + symbol=symbol, + limit_px=str(float(market_price) * 1.1), + is_buy=True, + time_in_force=TimeInForce.IOC, + qty="0.01", + reduce_only=False, + ) + await reya_tester.orders.create_limit(limit_order_params) + + # Wait for execution + expected_order = limit_order_params_to_order(limit_order_params, reya_tester.account_id) + await reya_tester.wait.for_order_execution(expected_order) + await reya_tester.check.no_open_orders() + + # SUBMIT TP + tp_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, # on long + trigger_px=str(float(market_price) * 2), # lower than IOC limit price + trigger_type=OrderType.TP, + ) + tp_order: CreateOrderResponse = await reya_tester.orders.create_trigger(tp_params) + logger.info(f"Created TP order with ID: {tp_order.order_id}") + + assert tp_order.order_id is not None + active_tp_order = await reya_tester.wait.for_order_creation(order_id=tp_order.order_id) + expected_tp_order = trigger_order_params_to_order(tp_params, reya_tester.account_id) + await reya_tester.check.open_order_created(tp_order.order_id, expected_tp_order) + # Get sequence after position was created (from initial limit order) + sequence_after_position = await reya_tester.get_last_perp_execution_sequence_number() + await reya_tester.check.position( + expected_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + symbol=symbol, + expected_qty="0.01", + expected_side=Side.A if tp_params.is_buy else Side.B, + ) + + # CANCEL order + await reya_tester.client.cancel_order(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) + await reya_tester.check.position( + expected_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + symbol=symbol, + expected_qty="0.01", + expected_side=Side.A if tp_params.is_buy else Side.B, + ) + + logger.info("TP order cancel test completed successfully") + + +@pytest.mark.asyncio +async def test_success_sl_order_create_cancel(reya_tester: ReyaTester): + """SL order, close right after creation""" + symbol = "ETHRUSDPERP" + + # SETUP - capture sequence number BEFORE any actions + _ = await reya_tester.get_last_perp_execution_sequence_number() + + market_price = await reya_tester.data.current_price(symbol) + + # Create initial limit order to establish position + limit_order_params = LimitOrderParameters( + symbol=symbol, + limit_px=str(float(market_price) * 1.1), + time_in_force=TimeInForce.IOC, + is_buy=True, # Create long position first + qty="0.01", + reduce_only=False, + ) + await reya_tester.orders.create_limit(limit_order_params) + + await reya_tester.check.no_open_orders() + + # SUBMIT SL + sl_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, # on long + trigger_px=str(float(market_price) * 0.9), # higher than IOC limit price + trigger_type=OrderType.SL, + ) + order_response = await reya_tester.orders.create_trigger(sl_params) + logger.info(f"Created SL order with ID: {order_response.order_id}") + + assert order_response.order_id is not None + active_sl_order = await reya_tester.wait.for_order_creation(order_id=order_response.order_id, timeout=10) + expected_sl_order = trigger_order_params_to_order(sl_params, reya_tester.account_id) + await reya_tester.check.open_order_created(order_response.order_id, expected_sl_order) + # Get sequence after position was created (from initial limit order) + sequence_after_position = await reya_tester.get_last_perp_execution_sequence_number() + await reya_tester.check.position( + expected_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + symbol=symbol, + expected_qty="0.01", + expected_side=Side.A if sl_params.is_buy else Side.B, + ) + + # CANCEL + await reya_tester.client.cancel_order(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( + expected_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + symbol=symbol, + expected_qty="0.01", + expected_side=Side.A if sl_params.is_buy else Side.B, + ) + + logger.info("SL order cancel test completed successfully") + + +# Constants for conditional order retry logic +CO_MAX_RETRIES = 5 +CO_TIMEOUT_PER_ATTEMPT = 30 + + +async def _cancel_order_if_open(reya_tester: ReyaTester, order_id: Optional[str]) -> 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.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 + + +# Note: the CO bot may not be active on testnet +@pytest.mark.asyncio +async def test_tp_in_cross_executes_immediately(reya_tester: ReyaTester): + """TP order executes immediately when trigger condition is already met (in-cross). + + Setup: SHORT position at ~1.0x market + TP trigger: 1.1x (above market) - condition "price <= 1.1x" is already TRUE + + Note: This tests the CO bot's in-cross detection, not a semantically correct TP. + A real TP for a short would have trigger BELOW entry (profit when price drops). + + Verifies: + 1. TP order executes immediately when in-cross + 2. Trade is consistent between REST and WS (same sequence number) + 3. Position is closed after execution + """ + symbol = "ETHRUSDPERP" + market_price = await reya_tester.data.current_price() + qty = "0.01" + + # SETUP: Create short position + async with reya_tester.perp_trade() as ctx: + limit_order_params = LimitOrderParameters( + symbol=symbol, + limit_px=str(float(market_price) * 0.99), + is_buy=False, + time_in_force=TimeInForce.IOC, + qty=qty, + 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) + result = await ctx.wait_for_execution(expected_order) + logger.info(f"Position created with trade seq={result.sequence_number}") + + await reya_tester.check.no_open_orders() + + # RETRY LOOP: CO bot may be slow + last_error: Exception | None = None + for attempt in range(1, CO_MAX_RETRIES + 1): + logger.info(f"🔄 TP execution attempt {attempt}/{CO_MAX_RETRIES}") + + # Verify position exists before placing CO + if await reya_tester.data.position(symbol) is None: + raise AssertionError(f"Position closed before placing TP (attempt {attempt})") + + async with reya_tester.perp_trade() as ctx: + tp_params = TriggerOrderParameters( + symbol=symbol, + is_buy=True, + trigger_px=str(float(market_price) * 1.1), + trigger_type=OrderType.TP, + ) + tp_order = await reya_tester.orders.create_trigger(tp_params) + logger.info(f"Created TP order: {tp_order.order_id}") + + try: + # PerpTradeContext handles all verification: + # - Waits for WS execution matching criteria + # - Fetches same trade from REST by sequence number + # - Verifies sequence numbers match + # - Waits for position to close + expected_tp = trigger_order_params_to_order(tp_params, reya_tester.account_id) + result = await ctx.wait_for_closing_execution(expected_tp, qty, timeout=CO_TIMEOUT_PER_ATTEMPT) + logger.info(f"✅ TP executed with trade seq={result.sequence_number}") + + # Verify execution fields match expected order + await reya_tester.check.order_execution(result.rest_execution, expected_tp, qty) + await reya_tester.check.position_not_open(symbol) + return # SUCCESS + + except RuntimeError as e: + last_error = e + logger.warning(f"⚠️ Attempt {attempt} failed: {e}") + await _cancel_order_if_open(reya_tester, tp_order.order_id) + continue + + raise AssertionError(f"TP order failed after {CO_MAX_RETRIES} attempts. Last error: {last_error}") + + +@pytest.mark.asyncio +async def test_sl_in_cross_executes_immediately(reya_tester: ReyaTester): + """SL order executes immediately when trigger condition is already met (in-cross). + + Setup: SHORT position at ~0.9x market + SL trigger: 0.9x (at entry) - condition "price >= 0.9x" is already TRUE + + Note: This tests the CO bot's in-cross detection. The SL is at entry price, + so it triggers immediately (current price ~1.0x >= 0.9x trigger). + + Verifies: + 1. SL order executes immediately when in-cross + 2. Trade is consistent between REST and WS (same sequence number) + 3. Position is closed after execution + """ + symbol = "ETHRUSDPERP" + market_price = await reya_tester.data.current_price() + qty = "0.01" + + # SETUP: Create short position + async with reya_tester.perp_trade() as ctx: + limit_order_params = LimitOrderParameters( + symbol=symbol, + limit_px=str(float(market_price) * 0.9), + is_buy=False, + time_in_force=TimeInForce.IOC, + qty=qty, + 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) + result = await ctx.wait_for_execution(expected_order) + logger.info(f"Position created with trade seq={result.sequence_number}") + + await reya_tester.check.no_open_orders() + + # RETRY LOOP: CO bot may be slow + last_error: Exception | None = None + for attempt in range(1, CO_MAX_RETRIES + 1): + logger.info(f"🔄 SL execution attempt {attempt}/{CO_MAX_RETRIES}") + + # Verify position exists before placing CO + if await reya_tester.data.position(symbol) is None: + raise AssertionError(f"Position closed before placing SL (attempt {attempt})") + + async with reya_tester.perp_trade() as ctx: + sl_params = TriggerOrderParameters( + symbol=symbol, + is_buy=True, + trigger_px=str(float(market_price) * 0.9), + trigger_type=OrderType.SL, + ) + sl_order = await reya_tester.orders.create_trigger(sl_params) + logger.info(f"Created SL order: {sl_order.order_id}") + + try: + expected_sl = trigger_order_params_to_order(sl_params, reya_tester.account_id) + result = await ctx.wait_for_closing_execution(expected_sl, qty, timeout=CO_TIMEOUT_PER_ATTEMPT) + logger.info(f"✅ SL executed with trade seq={result.sequence_number}") + + await reya_tester.check.order_execution(result.rest_execution, expected_sl, qty) + await reya_tester.check.position_not_open(symbol) + return # SUCCESS + + except RuntimeError as e: + last_error = e + logger.warning(f"⚠️ Attempt {attempt} failed: {e}") + await _cancel_order_if_open(reya_tester, sl_order.order_id) + continue + + raise AssertionError(f"SL order failed after {CO_MAX_RETRIES} attempts. Last error: {last_error}") + + +@pytest.mark.asyncio +async def test_failure_sltp_when_no_position(reya_tester: ReyaTester): + """SL/TP orders are immediately cancelled when no position exists. + + Setup: No position + Action: Submit SL and TP orders + + Verifies: + 1. SL order is immediately cancelled (not filled, not left open) + 2. TP order is immediately cancelled (not filled, not left open) + 3. No executions occur + """ + 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() + await reya_tester.check.position_not_open(symbol) + + # SUBMIT SL + sl_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, # on short position + trigger_px=str(float(market_price) * 0.9), # in the money + trigger_type=OrderType.SL, + ) + order_response_sl: CreateOrderResponse = await reya_tester.orders.create_trigger(sl_params) + # ENSURE IT WAS NOT FILLED NOR STILL OPENED + assert order_response_sl.order_id is not None + cancelled_or_rejected_order_id = await reya_tester.wait.for_order_state( + order_response_sl.order_id, OrderStatus.CANCELLED + ) + assert cancelled_or_rejected_order_id == order_response_sl.order_id, "SL order should not be opened" + await reya_tester.check.no_open_orders() + await reya_tester.check_no_order_execution_since(last_sequence_before) + + # SUBMIT TP + tp_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, # on short position + trigger_px=str(float(market_price) * 0.9), # in the money + trigger_type=OrderType.TP, + ) + order_response_tp: CreateOrderResponse = await reya_tester.orders.create_trigger(tp_params) + # ENSURE IT WAS NOT FILLED NOR STILL OPENED + assert order_response_tp.order_id is not None + cancelled_or_rejected_order_id = await reya_tester.wait.for_order_state( + order_response_tp.order_id, OrderStatus.CANCELLED + ) + assert cancelled_or_rejected_order_id == order_response_tp.order_id, "TP order should not be opened" + await reya_tester.check.no_open_orders() + await reya_tester.check_no_order_execution_since(last_sequence_before) + + +@pytest.mark.asyncio +async def test_failure_cancel_when_order_is_not_found(reya_tester: ReyaTester): + """Cancelling a non-existent order returns proper error. + + Verifies: + 1. API returns BadRequestException for unknown order ID + 2. Error message indicates the order was not found + 3. Error code is CANCEL_ORDER_OTHER_ERROR + """ + await reya_tester.check.no_open_orders() + try: + await reya_tester.client.cancel_order(order_id="unknown_id") + raise RuntimeError("Should have failed") + except BadRequestException as e: + assert e.data is not None + requestError: RequestError = e.data + assert requestError.message is not None + assert requestError.message.startswith( + "Missing order with id unknown_id" + ), f"Expected message to start with 'Missing order with id unknown_id', got: {requestError.message}" + assert requestError.error == RequestErrorCode.CANCEL_ORDER_OTHER_ERROR + + await reya_tester.check.no_open_orders() + logger.info("✅ Cancel non-existent order returns proper error") + + +@pytest.mark.asyncio +async def test_sltp_cancelled_when_position_closed(reya_tester: ReyaTester): + """SL/TP orders are cancelled when position is manually closed. + + Setup: LONG position with SL and TP orders (both not in-cross) + Action: Manually close position with market order + + Verifies: + 1. Position opens correctly + 2. SL and TP orders are created + 3. Manual close executes and closes position + 4. Both SL and TP orders are automatically cancelled + """ + symbol = "ETHRUSDPERP" + market_price = await reya_tester.data.current_price() + qty = "0.01" + + # SETUP: Create long position + async with reya_tester.perp_trade() as ctx: + limit_order_params = LimitOrderParameters( + symbol=symbol, + limit_px=str(float(market_price) * 1.01), + is_buy=True, + time_in_force=TimeInForce.IOC, + qty=qty, + 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) + result = await ctx.wait_for_execution(expected_order) + logger.info(f"Position created with trade seq={result.sequence_number}") + + await reya_tester.check.no_open_orders() + await reya_tester.check.position( + symbol=symbol, + expected_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + expected_qty=qty, + expected_side=Side.B, + ) + + # Create SL order (not in-cross: below market for long) + sl_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, + trigger_px=str(float(market_price) * 0.95), + trigger_type=OrderType.SL, + ) + sl_response = await reya_tester.orders.create_trigger(sl_params) + logger.info(f"Created SL order: {sl_response.order_id}") + + # Create TP order (not in-cross: above market for long) + tp_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, + trigger_px=str(float(market_price) * 1.05), + trigger_type=OrderType.TP, + ) + tp_response = await reya_tester.orders.create_trigger(tp_params) + logger.info(f"Created TP order: {tp_response.order_id}") + + await reya_tester.wait.for_order_creation(order_id=sl_response.order_id, timeout=10) + await reya_tester.wait.for_order_creation(order_id=tp_response.order_id, timeout=10) + + # Manually close position + async with reya_tester.perp_trade() as ctx: + close_order_params = LimitOrderParameters( + symbol=symbol, + limit_px="0", + is_buy=False, + time_in_force=TimeInForce.IOC, + qty=qty, + reduce_only=True, + ) + await reya_tester.orders.create_limit(close_order_params) + expected_close = limit_order_params_to_order(close_order_params, reya_tester.account_id) + result = await ctx.wait_for_closing_execution(expected_close, qty, timeout=10) + logger.info(f"Position closed with trade seq={result.sequence_number}") + + await reya_tester.check.position_not_open(symbol) + + # Verify both SL and TP orders are cancelled + await reya_tester.wait.for_order_state(sl_response.order_id, OrderStatus.CANCELLED, timeout=10) + await reya_tester.wait.for_order_state(tp_response.order_id, OrderStatus.CANCELLED, timeout=10) + await reya_tester.check.no_open_orders() + logger.info("✅ SL and TP orders cancelled when position was closed") + + +@pytest.mark.asyncio +async def test_sltp_cancelled_when_position_flipped(reya_tester: ReyaTester): + """SL/TP orders are cancelled when position is flipped (long to short). + + Setup: LONG position with SL and TP orders (both not in-cross) + Action: Flip position by selling 2x the position size + + Verifies: + 1. Long position opens correctly + 2. SL and TP orders are created + 3. Position flips to short after selling 2x size + 4. Both SL and TP orders are automatically cancelled + """ + symbol = "ETHRUSDPERP" + market_price = await reya_tester.data.current_price() + qty = "0.01" + + # SETUP: Create long position + async with reya_tester.perp_trade() as ctx: + limit_order_params = LimitOrderParameters( + symbol=symbol, + limit_px=str(float(market_price) * 1.01), + is_buy=True, + time_in_force=TimeInForce.IOC, + qty=qty, + 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) + result = await ctx.wait_for_execution(expected_order) + logger.info(f"Long position created with trade seq={result.sequence_number}") + + await reya_tester.check.no_open_orders() + await reya_tester.check.position( + symbol=symbol, + expected_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + expected_qty=qty, + expected_side=Side.B, + ) + + # Create SL order (not in-cross: below market for long) + sl_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, + trigger_px=str(float(market_price) * 0.95), + trigger_type=OrderType.SL, + ) + sl_response = await reya_tester.orders.create_trigger(sl_params) + logger.info(f"Created SL order: {sl_response.order_id}") + + # Create TP order (not in-cross: above market for long) + tp_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, + trigger_px=str(float(market_price) * 1.05), + trigger_type=OrderType.TP, + ) + tp_response = await reya_tester.orders.create_trigger(tp_params) + logger.info(f"Created TP order: {tp_response.order_id}") + + await reya_tester.wait.for_order_creation(order_id=sl_response.order_id, timeout=10) + await reya_tester.wait.for_order_creation(order_id=tp_response.order_id, timeout=10) + + # Flip position by selling 2x (0.01 long -> 0.01 short) + async with reya_tester.perp_trade() as ctx: + flip_order_params = LimitOrderParameters( + symbol=symbol, + limit_px="0", + is_buy=False, + time_in_force=TimeInForce.IOC, + qty="0.02", + reduce_only=False, + ) + await reya_tester.orders.create_limit(flip_order_params) + expected_flip = limit_order_params_to_order(flip_order_params, reya_tester.account_id) + result = await ctx.wait_for_execution(expected_flip, expected_qty="0.02") + logger.info(f"Position flipped with trade seq={result.sequence_number}") + + # Verify position is now short + await reya_tester.check.position( + symbol=symbol, + expected_exchange_id=REYA_DEX_ID, + expected_account_id=reya_tester.account_id, + expected_qty=qty, + expected_side=Side.A, + ) + + # Verify both SL and TP orders are cancelled + await reya_tester.wait.for_order_state(sl_response.order_id, OrderStatus.CANCELLED, timeout=10) + await reya_tester.wait.for_order_state(tp_response.order_id, OrderStatus.CANCELLED, timeout=10) + await reya_tester.check.no_open_orders() + logger.info("✅ SL and TP orders cancelled when position was flipped") + + +@pytest.mark.asyncio +async def test_sl_execution_cancels_tp(reya_tester: ReyaTester): + """SL executes (in-cross) and cancels the TP order. + + Setup: LONG position with both SL and TP orders + SL trigger: At entry price (in-cross for long SL) + TP trigger: Above market (not in-cross) + + Verifies: + 1. SL order executes correctly when in-cross + 2. Trade is consistent between REST and WS (same sequence number) + 3. TP order gets cancelled when position closes + """ + symbol = "ETHRUSDPERP" + market_price = await reya_tester.data.current_price() + qty = "0.01" + + # SETUP: Create long position + async with reya_tester.perp_trade() as ctx: + limit_order_params = LimitOrderParameters( + symbol=symbol, + limit_px=str(float(market_price) * 1.01), + is_buy=True, + time_in_force=TimeInForce.IOC, + qty=qty, + 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) + result = await ctx.wait_for_execution(expected_order) + logger.info(f"Position created with trade seq={result.sequence_number}") + + await reya_tester.check.no_open_orders() + + # RETRY LOOP: CO bot may be slow + last_error: Exception | None = None + for attempt in range(1, CO_MAX_RETRIES + 1): + logger.info(f"🔄 SL execution attempt {attempt}/{CO_MAX_RETRIES}") + + # Verify position exists before placing CO + if await reya_tester.data.position(symbol) is None: + raise AssertionError(f"Position closed before placing SL (attempt {attempt})") + + async with reya_tester.perp_trade() as ctx: + # Create SL order (in cross - should trigger) + sl_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, + trigger_px=str(float(market_price) * 1.01), + trigger_type=OrderType.SL, + ) + sl_order = await reya_tester.orders.create_trigger(sl_params) + logger.info(f"Created SL order: {sl_order.order_id}") + + # Create TP order (not in cross - should be cancelled when SL executes) + tp_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, + trigger_px=str(float(market_price) * 1.10), + trigger_type=OrderType.TP, + ) + tp_order = await reya_tester.orders.create_trigger(tp_params) + logger.info(f"Created TP order: {tp_order.order_id}") + + try: + # Wait for SL execution + expected_sl = trigger_order_params_to_order(sl_params, reya_tester.account_id) + result = await ctx.wait_for_closing_execution(expected_sl, qty, timeout=CO_TIMEOUT_PER_ATTEMPT) + logger.info(f"✅ SL executed with trade seq={result.sequence_number}") + + await reya_tester.check.order_execution(result.rest_execution, expected_sl, qty) + await reya_tester.check.position_not_open(symbol) + + # Verify TP order gets cancelled + await reya_tester.wait.for_order_state(tp_order.order_id, OrderStatus.CANCELLED, timeout=10) + await reya_tester.check.no_open_orders() + logger.info("✅ TP order cancelled when SL executed") + return # SUCCESS + + except RuntimeError as e: + last_error = e + logger.warning(f"⚠️ Attempt {attempt} failed: {e}") + await _cancel_order_if_open(reya_tester, sl_order.order_id) + await _cancel_order_if_open(reya_tester, tp_order.order_id) + continue + + raise AssertionError(f"SL order failed after {CO_MAX_RETRIES} attempts. Last error: {last_error}") + + +@pytest.mark.asyncio +async def test_tp_execution_cancels_sl(reya_tester: ReyaTester): + """TP executes (in-cross) and cancels the SL order. + + Setup: LONG position with both SL and TP orders + SL trigger: Below market (not in-cross) + TP trigger: Below market (in-cross for long TP) + + Verifies: + 1. TP order executes correctly when in-cross + 2. Trade is consistent between REST and WS (same sequence number) + 3. SL order gets cancelled when position closes + """ + symbol = "ETHRUSDPERP" + market_price = await reya_tester.data.current_price() + qty = "0.01" + + # SETUP: Create long position + async with reya_tester.perp_trade() as ctx: + limit_order_params = LimitOrderParameters( + symbol=symbol, + limit_px=str(float(market_price) * 1.01), + is_buy=True, + time_in_force=TimeInForce.IOC, + qty=qty, + 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) + result = await ctx.wait_for_execution(expected_order) + logger.info(f"Position created with trade seq={result.sequence_number}") + + await reya_tester.check.no_open_orders() + + # RETRY LOOP: CO bot may be slow + last_error: Exception | None = None + for attempt in range(1, CO_MAX_RETRIES + 1): + logger.info(f"🔄 TP execution attempt {attempt}/{CO_MAX_RETRIES}") + + # Verify position exists before placing CO + if await reya_tester.data.position(symbol) is None: + raise AssertionError(f"Position closed before placing TP (attempt {attempt})") + + async with reya_tester.perp_trade() as ctx: + # Create SL order (not in cross - should be cancelled when TP executes) + sl_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, + trigger_px=str(float(market_price) * 0.90), + trigger_type=OrderType.SL, + ) + sl_order = await reya_tester.orders.create_trigger(sl_params) + logger.info(f"Created SL order: {sl_order.order_id}") + + # Create TP order (in cross - should trigger) + tp_params = TriggerOrderParameters( + symbol=symbol, + is_buy=False, + trigger_px=str(float(market_price) * 0.99), + trigger_type=OrderType.TP, + ) + tp_order = await reya_tester.orders.create_trigger(tp_params) + logger.info(f"Created TP order: {tp_order.order_id}") + + try: + # Wait for TP execution + expected_tp = trigger_order_params_to_order(tp_params, reya_tester.account_id) + result = await ctx.wait_for_closing_execution(expected_tp, qty, timeout=CO_TIMEOUT_PER_ATTEMPT) + logger.info(f"✅ TP executed with trade seq={result.sequence_number}") + + await reya_tester.check.order_execution(result.rest_execution, expected_tp, qty) + await reya_tester.check.position_not_open(symbol) + + # Verify SL order gets cancelled + await reya_tester.wait.for_order_state(sl_order.order_id, OrderStatus.CANCELLED, timeout=10) + await reya_tester.check.no_open_orders() + logger.info("✅ SL order cancelled when TP executed") + return # SUCCESS + + except RuntimeError as e: + last_error = e + logger.warning(f"⚠️ Attempt {attempt} failed: {e}") + await _cancel_order_if_open(reya_tester, sl_order.order_id) + await _cancel_order_if_open(reya_tester, tp_order.order_id) + continue + + raise AssertionError(f"TP order failed after {CO_MAX_RETRIES} attempts. Last error: {last_error}") diff --git a/tests/test_perps/test_wallet_data.py b/tests/test_perps/test_wallet_data.py new file mode 100644 index 00000000..484f5778 --- /dev/null +++ b/tests/test_perps/test_wallet_data.py @@ -0,0 +1,198 @@ +#!/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. +""" + +import pytest + +from sdk.open_api.models.side import Side +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api.config import REYA_DEX_ID +from sdk.reya_rest_api.models import LimitOrderParameters +from tests.helpers import ReyaTester +from tests.helpers.reya_tester import limit_order_params_to_order, logger + + +@pytest.mark.asyncio +async def test_get_wallet_positions_empty(reya_tester: ReyaTester): + """Test getting positions when no positions exist for the symbol""" + symbol = "ETHRUSDPERP" + + await reya_tester.check.no_open_orders() + await reya_tester.check.position_not_open(symbol) + + positions = await reya_tester.data.positions() + assert isinstance(positions, dict), "Positions should be a dictionary" + assert symbol not in positions, f"Should not have position for {symbol}" + + logger.info("✅ Wallet positions (empty) test completed successfully") + + +@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, + ) + + 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() + assert isinstance(positions, dict), "Positions should be a dictionary" + assert symbol in positions, f"Should have position for {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" + assert position.exchange_id == REYA_DEX_ID, "Position exchange ID should match" + assert float(position.qty) == float(test_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" + + await reya_tester.check.no_open_orders() + await reya_tester.check.position_not_open(symbol) + + last_execution_before = await reya_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, + ) + + 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) + + last_execution_after = await reya_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.exchange_id == REYA_DEX_ID, "Execution exchange ID should match" + assert float(last_execution_after.qty) == float(test_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") + + +@pytest.mark.asyncio +async def test_get_wallet_accounts(reya_tester: ReyaTester): + """Test getting wallet accounts""" + assert reya_tester.owner_wallet_address is not None, "Owner wallet address required" + + accounts = await reya_tester.client.wallet.get_wallet_accounts(address=reya_tester.owner_wallet_address) + + assert accounts is not None, "Should have accounts" + assert len(accounts) > 0, "Should have at least one account" + + account_ids = [acc.account_id for acc in accounts] + assert reya_tester.account_id in account_ids, "Test account should be in accounts list" + + test_account = next(acc for acc in accounts if acc.account_id == reya_tester.account_id) + assert test_account.name is not None, "Account should have a name" + assert test_account.type is not None, "Account should have a type" + + logger.info(f"✅ Wallet accounts test completed - found {len(accounts)} account(s)") + + +@pytest.mark.asyncio +async def test_get_wallet_account_balances(reya_tester: ReyaTester): + """Test getting wallet account balances""" + balances = await reya_tester.data.balances() + + assert isinstance(balances, dict), "Balances should be a dictionary" + assert "RUSD" in balances or len(balances) > 0, "Should have at least one balance" + + for asset, balance in balances.items(): + assert balance.account_id == reya_tester.account_id, f"Balance account ID should match for {asset}" + assert balance.asset == asset, f"Balance asset should match for {asset}" + assert balance.real_balance is not None, f"Balance should have real_balance for {asset}" + + logger.info(f"✅ Wallet account balances test completed - found {len(balances)} balance(s)") + + +@pytest.mark.asyncio +async def test_get_wallet_configuration(reya_tester: ReyaTester): + """Test getting wallet configuration (fee tier, OG status, affiliate status)""" + assert reya_tester.owner_wallet_address is not None, "Owner wallet address required" + + config = await reya_tester.client.wallet.get_wallet_configuration(address=reya_tester.owner_wallet_address) + + assert config is not None, "Should have wallet configuration" + assert config.fee_tier_id is not None, "Should have fee tier ID" + assert config.fee_tier_id >= 0, "Fee tier ID should be non-negative" + assert config.og_status is not None, "Should have OG status" + assert config.affiliate_status is not None, "Should have affiliate status" + assert config.referee_status is not None, "Should have referee status" + + logger.info(f"✅ Wallet configuration test completed - fee tier: {config.fee_tier_id}") + + +@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, + ) + + 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) + 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" + + logger.info("✅ Get single position test completed successfully") diff --git a/tests/test_spot/__init__.py b/tests/test_spot/__init__.py new file mode 100644 index 00000000..a16bb63b --- /dev/null +++ b/tests/test_spot/__init__.py @@ -0,0 +1,6 @@ +""" +Spot trading tests for Reya Python SDK. + +This package contains end-to-end tests for spot trading functionality, +covering order creation, execution, cancellation, and balance verification. +""" diff --git a/tests/test_spot/conftest.py b/tests/test_spot/conftest.py new file mode 100644 index 00000000..7d7eb491 --- /dev/null +++ b/tests/test_spot/conftest.py @@ -0,0 +1,18 @@ +""" +Spot-specific pytest fixtures. + +This conftest ensures spot fixtures are only initialized when running spot tests. +""" + +import pytest + + +@pytest.fixture(scope="session", autouse=True) +def spot_test_guard(spot_balance_guard): + """ + Auto-use fixture that ensures spot_balance_guard runs for all spot tests. + + This fixture is only loaded when running tests in the test_spot directory, + ensuring perp tests don't trigger spot account initialization. + """ + yield spot_balance_guard diff --git a/tests/test_spot/spot_config.py b/tests/test_spot/spot_config.py new file mode 100644 index 00000000..b0787ed5 --- /dev/null +++ b/tests/test_spot/spot_config.py @@ -0,0 +1,193 @@ +""" +Centralized SPOT test configuration with smart liquidity detection. + +This module provides a SpotTestConfig dataclass that holds all test configuration +including dynamically fetched oracle prices and order book liquidity state. +The config is injected via pytest fixtures - no global state is used. + +Usage in test files: + @pytest.mark.asyncio + async def test_something(spot_config: SpotTestConfig, maker_tester: ReyaTester): + # Refresh liquidity state before test logic + await spot_config.refresh_order_book(maker_tester.data) + + # Check if external liquidity is available + if spot_config.has_usable_bid_liquidity: + fill_price = spot_config.best_bid_price + else: + # Provide our own liquidity + ... +""" + +from typing import TYPE_CHECKING, Optional + +import logging +from dataclasses import dataclass, field +from decimal import Decimal + +from tests.helpers.liquidity_detector import ( + SAFE_NO_MATCH_BUY_PRICE, + SAFE_NO_MATCH_SELL_PRICE, + LiquidityDetector, + OrderBookState, + log_order_book_state, +) + +if TYPE_CHECKING: + from tests.helpers.reya_tester.data import DataOperations + +logger = logging.getLogger("reya.integration_tests") + + +@dataclass +class SpotTestConfig: + """ + Centralized configuration for SPOT tests with smart liquidity detection. + + This dataclass is populated by a pytest fixture at session start, + ensuring all tests use consistent, dynamically-fetched values. + + Attributes: + symbol: The spot market symbol (e.g., "WETHRUSD") + min_qty: Minimum order quantity as string (e.g., "0.001") + oracle_price: Current oracle price for the underlying asset + """ + + symbol: str + min_qty: str + oracle_price: float + _order_book: Optional[OrderBookState] = field(default=None, repr=False) + + def price(self, multiplier: float = 1.0) -> float: + """ + Get a price based on oracle price with optional multiplier. + + Args: + multiplier: Price multiplier (e.g., 0.99 for 99% of oracle) + + Returns: + Rounded price to 2 decimal places + """ + return round(self.oracle_price * multiplier, 2) + + def buy_price(self, multiplier: float = 0.99) -> float: + """Get a buy price (default 99% of oracle - within deviation limit).""" + return self.price(multiplier) + + def sell_price(self, multiplier: float = 1.01) -> float: + """Get a sell price (default 101% of oracle - within deviation limit).""" + return self.price(multiplier) + + async def refresh_order_book(self, data_ops: "DataOperations") -> OrderBookState: + """ + Refresh the order book state from the API. + + This should be called at the start of each test that needs liquidity detection. + Always fetches fresh data to ensure accuracy. + + Args: + data_ops: DataOperations instance for API calls. + + Returns: + Current 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) -> Optional[OrderBookState]: + """Get the current order book state (may be None if not refreshed).""" + return self._order_book + + @property + def has_any_external_liquidity(self) -> bool: + """True if any external liquidity exists on either side.""" + if self._order_book is None: + return False + return self._order_book.has_any_liquidity + + @property + def has_usable_bid_liquidity(self) -> bool: + """True if usable bid liquidity exists (for sell orders to aggress).""" + if self._order_book is None: + return False + return self._order_book.has_usable_bid_liquidity + + @property + def has_usable_ask_liquidity(self) -> bool: + """True if usable ask liquidity exists (for buy orders to aggress).""" + if self._order_book is None: + return False + return self._order_book.has_usable_ask_liquidity + + @property + def best_bid_price(self) -> Optional[Decimal]: + """Get the best bid price, or None if no bids.""" + 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) -> Optional[Decimal]: + """Get the best ask price, or None if no asks.""" + 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: + """Minimum allowed price (oracle - 5%).""" + return (Decimal(str(self.oracle_price)) * Decimal("0.95")).quantize(Decimal("0.01")) + + @property + def circuit_breaker_ceiling(self) -> Decimal: + """Maximum allowed price (oracle + 5%).""" + return (Decimal(str(self.oracle_price)) * Decimal("1.05")).quantize(Decimal("0.01")) + + def get_usable_bid_price_for_qty(self, qty: str) -> Optional[Decimal]: + """ + Get best bid price with sufficient quantity for a sell order. + + Args: + qty: Required quantity. + + Returns: + Best usable bid price, or None if insufficient liquidity. + """ + if self._order_book is None: + return None + detector = LiquidityDetector(self.oracle_price) + return detector.get_usable_bid_price(self._order_book, qty) + + def get_usable_ask_price_for_qty(self, qty: str) -> Optional[Decimal]: + """ + Get best ask price with sufficient quantity for a buy order. + + Args: + qty: Required quantity. + + Returns: + Best usable ask price, or None if insufficient liquidity. + """ + if self._order_book is None: + return None + detector = LiquidityDetector(self.oracle_price) + return detector.get_usable_ask_price(self._order_book, qty) + + def get_safe_no_match_buy_price(self) -> Decimal: + """ + Get a buy price guaranteed not to match any existing asks. + + Returns $10 - an extreme low price that will never match. + """ + return SAFE_NO_MATCH_BUY_PRICE + + def get_safe_no_match_sell_price(self) -> Decimal: + """ + Get a sell price guaranteed not to match any existing bids. + + Returns $10,000,000 - an extreme high price that will never match. + """ + return SAFE_NO_MATCH_SELL_PRICE diff --git a/tests/test_spot/test_api_validation.py b/tests/test_spot/test_api_validation.py new file mode 100644 index 00000000..c6491651 --- /dev/null +++ b/tests/test_spot/test_api_validation.py @@ -0,0 +1,1987 @@ +""" +Tests for API server validation checks in the matching-engine controller. + +These tests verify that the API properly validates: +- Signature validity (EIP-712 signature verification) +- Nonce validity (monotonically increasing) +- Deadline validity (not expired) +- Balance validity (IOC orders) +- Price/Qty step size validity + +High and Medium priority validation tests for spot market orders. +""" + +import asyncio +import time +from decimal import Decimal + +import aiohttp +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.cancel_order_request import CancelOrderRequest +from sdk.open_api.models.create_order_request import CreateOrderRequest +from sdk.open_api.models.mass_cancel_request import MassCancelRequest +from sdk.open_api.models.order_type import OrderType +from sdk.open_api.models.time_in_force import TimeInForce +from sdk.reya_rest_api.auth.signatures import SignatureGenerator +from sdk.reya_rest_api.config import TradingConfig +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.reya_tester import logger +from tests.test_spot.spot_config import SpotTestConfig + +# Test configuration +SPOT_MARKET_ID = 5 + + +# SIGNATURE VALIDATION TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_invalid_signature(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order with an invalid signature is rejected. + + The API should verify the EIP-712 signature and reject orders + where the signature doesn't match the order data. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER INVALID SIGNATURE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Create a valid order request but with a tampered signature + order_price = spot_config.price(0.96) + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + # Use a completely fake signature (valid format but wrong data) + fake_signature = "0x" + "ab" * 65 # 65 bytes = r(32) + s(32) + v(1) + + order_request = CreateOrderRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + exchangeId=spot_tester.client.config.dex_id, + isBuy=True, + limitPx=str(order_price), + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + expiresAfter=deadline, + reduceOnly=None, + signature=fake_signature, + nonce=str(nonce), + signerWallet=spot_tester.client.signer_wallet_address, + ) + + logger.info("Sending order with invalid signature...") + + try: + response = await spot_tester.client.orders.create_order(create_order_request=order_request) + pytest.fail(f"Order with invalid signature should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e) + # Expect: error=CREATE_ORDER_OTHER_ERROR message='Invalid signature' + assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" + assert "Invalid signature" in error_msg, f"Expected 'Invalid signature' message, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT ORDER INVALID SIGNATURE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_wrong_signer(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order signed by a different wallet is rejected. + + The API should verify that the signer has permission to trade + on the specified account. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER WRONG SIGNER TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Create a different private key for signing + wrong_private_key = "0x" + "12" * 32 # A different private key + + # Create config with different private key + # Use the same config values but with a different private key + wrong_config = TradingConfig( + api_url=spot_tester.client.config.api_url, + chain_id=spot_tester.chain_id, + owner_wallet_address=spot_tester.client.config.owner_wallet_address, + private_key=wrong_private_key, + account_id=spot_tester.account_id, + ) + wrong_signer = SignatureGenerator(wrong_config) + + order_price = spot_config.price(0.96) + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + 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 + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, # LIMIT_ORDER_SPOT + inputs=inputs, + deadline=deadline, + nonce=nonce, + ) + + order_request = CreateOrderRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + exchangeId=spot_tester.client.config.dex_id, + isBuy=True, + limitPx=str(order_price), + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + expiresAfter=deadline, + reduceOnly=None, + signature=signature, + nonce=str(nonce), + signerWallet=wrong_signer.signer_wallet_address, # Wrong signer + ) + + logger.info(f"Sending order signed by wrong wallet: {wrong_signer.signer_wallet_address}") + + try: + response = await spot_tester.client.orders.create_order(create_order_request=order_request) + pytest.fail(f"Order from unauthorized signer should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e) + # Expect either: + # - CREATE_ORDER_OTHER_ERROR with 'Invalid signature' (signature validation fails), or + # - CANCEL_ORDER_OTHER_ERROR with 'Unauthorized: signer does not have permission' (permission check fails) + has_valid_error = ("CREATE_ORDER_OTHER_ERROR" in error_msg and "Invalid signature" in error_msg) or ( + "Unauthorized: signer does not have permission" in error_msg + ) + assert has_valid_error, f"Expected signature or permission error, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT ORDER WRONG SIGNER TEST COMPLETED") + + +# ============================================================================ +# DEADLINE VALIDATION TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_expired_deadline(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order with an expired deadline is rejected. + + The API should check that deadline > current time. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER EXPIRED DEADLINE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + order_price = spot_config.price(0.96) + # Set deadline in the past + expired_deadline = int(time.time()) - 60 # 1 minute ago (in seconds) + nonce = spot_tester.get_next_nonce() + + # 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, # LIMIT_ORDER_SPOT + inputs=inputs, + deadline=expired_deadline, + nonce=nonce, + ) + + order_request = CreateOrderRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + exchangeId=spot_tester.client.config.dex_id, + isBuy=True, + limitPx=str(order_price), + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + expiresAfter=expired_deadline, + reduceOnly=None, + signature=signature, + nonce=str(nonce), + signerWallet=spot_tester.client.signer_wallet_address, + ) + + logger.info(f"Sending order with expired deadline: {expired_deadline}") + + try: + response = await spot_tester.client.orders.create_order(create_order_request=order_request) + pytest.fail(f"Order with expired deadline should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e) + # API should reject with 400 error - check for deadline-related keywords + assert ( + "400" in error_msg or "deadline" in error_msg.lower() or "expired" in error_msg.lower() + ), f"Expected deadline rejection error, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT ORDER EXPIRED DEADLINE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_cancel_expired_deadline(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a cancel request with an expired deadline is rejected. + """ + logger.info("=" * 80) + logger.info("SPOT CANCEL EXPIRED DEADLINE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # First create a valid order + order_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .buy() + .price(str(spot_config.price(0.96))) + .qty(spot_config.min_qty) + .gtc() + .build() + ) + + order_id = await spot_tester.orders.create_limit(order_params) + assert order_id is not None, "Order creation should return order_id for GTC orders" + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"Created order: {order_id}") + + # Now try to cancel with expired deadline + expired_deadline = int(time.time()) - 60 # 1 minute ago (in seconds) + nonce = spot_tester.get_next_nonce() + + sig_gen = spot_tester.client.signature_generator + signature = sig_gen.sign_cancel_order_spot( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + order_id=int(order_id), + client_order_id=0, + nonce=nonce, + deadline=expired_deadline, + ) + + cancel_request = CancelOrderRequest( + orderId=order_id, + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + signature=signature, + nonce=str(nonce), + expiresAfter=expired_deadline, + ) + + logger.info(f"Sending cancel with expired deadline: {expired_deadline}") + + try: + response = await spot_tester.client.orders.cancel_order(cancel_order_request=cancel_request) + pytest.fail(f"Cancel with expired deadline should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e) + # API should reject with 400 error - check for deadline-related keywords + assert ( + "400" in error_msg or "deadline" in error_msg.lower() or "expired" in error_msg.lower() + ), f"Expected deadline rejection error, got: {e}" + logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + # Clean up - cancel with valid request + await spot_tester.client.cancel_order( + order_id=order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT CANCEL EXPIRED DEADLINE TEST COMPLETED") + + +# ============================================================================ +# NONCE VALIDATION TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_reused_nonce(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that reusing a nonce is rejected. + + Nonces must be monotonically increasing to prevent replay attacks. + First sends a valid order with a specific nonce, then tries to reuse that nonce. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER REUSED NONCE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + order_price = spot_config.price(0.96) + + # Step 1: Create a valid order with a specific nonce to establish it + first_nonce = spot_tester.get_next_nonce() + first_deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, + inputs=inputs, + deadline=first_deadline, + nonce=first_nonce, + ) + + first_order_request = CreateOrderRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + exchangeId=spot_tester.client.config.dex_id, + isBuy=True, + limitPx=str(order_price), + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + expiresAfter=first_deadline, + reduceOnly=None, + signature=first_signature, + nonce=str(first_nonce), + signerWallet=spot_tester.client.signer_wallet_address, + ) + + logger.info(f"Step 1: Sending first order with nonce: {first_nonce}") + first_response = await spot_tester.client.orders.create_order(create_order_request=first_order_request) + logger.info(f"✅ First order created: {first_response.order_id}") + + # Cancel the first order to clean up + await spot_tester.client.cancel_order( + order_id=first_response.order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.1) + + # 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, + inputs=inputs, + deadline=reused_deadline, + nonce=first_nonce, # Reuse the same nonce + ) + + reused_order_request = CreateOrderRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + exchangeId=spot_tester.client.config.dex_id, + isBuy=True, + limitPx=str(order_price), + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + expiresAfter=reused_deadline, + reduceOnly=None, + signature=reused_signature, + nonce=str(first_nonce), # Reuse the same nonce + signerWallet=spot_tester.client.signer_wallet_address, + ) + + logger.info(f"Step 2: Sending order with reused nonce: {first_nonce}") + + try: + response = await spot_tester.client.orders.create_order(create_order_request=reused_order_request) + pytest.fail(f"Order with reused nonce should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + # API should reject with 400 error - check for nonce-related keywords + assert ( + "400" in str(e) or "nonce" in error_msg or "invalid" in error_msg + ), f"Expected nonce rejection error, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT ORDER REUSED NONCE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_old_nonce(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that using an old nonce (nonce - 1) is rejected. + + Nonces must be monotonically increasing to prevent replay attacks. + First sends a valid order with a specific nonce, then tries nonce-1. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER OLD NONCE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + order_price = spot_config.price(0.96) + + # Step 1: Create a valid order with a specific nonce to establish it + first_nonce = spot_tester.get_next_nonce() + first_deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, + inputs=inputs, + deadline=first_deadline, + nonce=first_nonce, + ) + + first_order_request = CreateOrderRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + exchangeId=spot_tester.client.config.dex_id, + isBuy=True, + limitPx=str(order_price), + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + expiresAfter=first_deadline, + reduceOnly=None, + signature=first_signature, + nonce=str(first_nonce), + signerWallet=spot_tester.client.signer_wallet_address, + ) + + logger.info(f"Step 1: Sending first order with nonce: {first_nonce}") + first_response = await spot_tester.client.orders.create_order(create_order_request=first_order_request) + logger.info(f"✅ First order created: {first_response.order_id}") + + # Cancel the first order to clean up + await spot_tester.client.cancel_order( + order_id=first_response.order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.1) + + # Step 2: Try to use nonce - 1 - should fail + old_nonce = first_nonce - 1 + old_deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + + old_signature = sig_gen.sign_raw_order( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, + inputs=inputs, + deadline=old_deadline, + nonce=old_nonce, # Use nonce - 1 + ) + + old_order_request = CreateOrderRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + exchangeId=spot_tester.client.config.dex_id, + isBuy=True, + limitPx=str(order_price), + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + expiresAfter=old_deadline, + reduceOnly=None, + signature=old_signature, + nonce=str(old_nonce), # Use nonce - 1 + signerWallet=spot_tester.client.signer_wallet_address, + ) + + logger.info(f"Step 2: Sending order with old nonce (nonce-1): {old_nonce}") + + try: + response = await spot_tester.client.orders.create_order(create_order_request=old_order_request) + pytest.fail(f"Order with old nonce should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + # API should reject with 400 error - check for nonce-related keywords + assert ( + "400" in str(e) or "nonce" in error_msg or "invalid" in error_msg + ), f"Expected nonce rejection error, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT ORDER OLD NONCE TEST COMPLETED") + + +# ============================================================================ +# BALANCE VALIDATION TESTS (IOC ORDERS) +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_ioc_insufficient_balance_buy(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an IOC buy order exceeding RUSD balance is rejected. + + IOC orders have pre-trade balance validation to prevent failed executions. + Gets the actual RUSD balance and tries to exceed it by a small amount. + """ + logger.info("=" * 80) + logger.info("SPOT IOC INSUFFICIENT BALANCE (BUY) TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Get the actual RUSD balance for this account + balances = await spot_tester.client.get_account_balances() + rusd_balance = None + for b in balances: + if b.account_id == spot_tester.account_id and b.asset == "RUSD": + rusd_balance = Decimal(b.real_balance) + break + + if rusd_balance is None or rusd_balance <= 0: + pytest.skip("No RUSD balance available for this test") + + logger.info(f"Current RUSD balance: {rusd_balance}") + + # Calculate qty that would require slightly more RUSD than available + # At spot_config.oracle_price, we need (rusd_balance / price) + small_extra ETH + order_price = Decimal(str(spot_config.oracle_price)) + max_qty_at_price = rusd_balance / order_price + # Request 10% more than we can afford + exceeding_qty = str((max_qty_at_price * Decimal("1.1")).quantize(Decimal("0.01"))) + + order_params = ( + OrderBuilder().symbol(spot_config.symbol).buy().price(str(order_price)).qty(exceeding_qty).ioc().build() + ) + + required_rusd = Decimal(exceeding_qty) * order_price + logger.info(f"Sending IOC buy for {exceeding_qty} ETH @ ${order_price}") + logger.info(f"Required RUSD: {required_rusd}, Available: {rusd_balance}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + pytest.fail(f"Order exceeding balance should have been rejected, got: {order_id}") + except ApiException as e: + error_msg = str(e) + # Expect: error=CREATE_ORDER_OTHER_ERROR message='Insufficient balance: required X, available Y' + assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" + assert "Insufficient balance" in error_msg, f"Expected 'Insufficient balance' message, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT IOC INSUFFICIENT BALANCE (BUY) TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_ioc_insufficient_balance_sell(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an IOC sell order exceeding ETH balance is rejected. + + IOC orders have pre-trade balance validation to prevent failed executions. + Gets the actual ETH balance and tries to exceed it by a small amount. + """ + logger.info("=" * 80) + logger.info("SPOT IOC INSUFFICIENT BALANCE (SELL) TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Get the actual ETH balance for this account + balances = await spot_tester.client.get_account_balances() + eth_balance = None + for b in balances: + if b.account_id == spot_tester.account_id and b.asset == "ETH": + eth_balance = Decimal(b.real_balance) + break + + if eth_balance is None or eth_balance <= 0: + pytest.skip("No ETH balance available for this test") + + logger.info(f"Current ETH balance: {eth_balance}") + + # Request 10% more ETH than we have + exceeding_qty = str((eth_balance * Decimal("1.1")).quantize(Decimal("0.01"))) + order_price = str(spot_config.oracle_price) + + order_params = OrderBuilder().symbol(spot_config.symbol).sell().price(order_price).qty(exceeding_qty).ioc().build() + + logger.info(f"Sending IOC sell for {exceeding_qty} ETH @ ${order_price}") + logger.info(f"Required ETH: {exceeding_qty}, Available: {eth_balance}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + pytest.fail(f"Order exceeding balance should have been rejected, got: {order_id}") + except ApiException as e: + error_msg = str(e) + # Expect: error=CREATE_ORDER_OTHER_ERROR message='Insufficient balance: required X, available Y' + assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" + assert "Insufficient balance" in error_msg, f"Expected 'Insufficient balance' message, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT IOC INSUFFICIENT BALANCE (SELL) TEST COMPLETED") + + +# ============================================================================ +# PRICE/QTY STEP SIZE VALIDATION TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_qty_below_minimum(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order with quantity below minimum is rejected. + + Each market has a minimum order base (e.g., 0.001 for WETHRUSD). + """ + logger.info("=" * 80) + logger.info("SPOT ORDER QTY BELOW MINIMUM TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Use a quantity below the minimum (0.001 for WETHRUSD) + tiny_qty = "0.0001" # Below minimum of 0.001 + order_price = str(spot_config.price(0.96)) + + order_params = OrderBuilder().symbol(spot_config.symbol).buy().price(order_price).qty(tiny_qty).gtc().build() + + logger.info(f"Sending order with qty below minimum: {tiny_qty}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + # If accepted, clean up + await spot_tester.client.cancel_order( + order_id=order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.05) + pytest.fail(f"Order with qty below minimum should have been rejected, got: {order_id}") + except ApiException as e: + error_msg = str(e) + # Expect: error=CREATE_ORDER_OTHER_ERROR message='Order quantity X is below minimum order base Y' + assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" + assert "is below minimum order base" in error_msg, f"Expected 'is below minimum order base' message, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT ORDER QTY BELOW MINIMUM TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_qty_not_step_multiple(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order with quantity not a multiple of step size is rejected. + + Quantities must be multiples of the market's base step size. + Note: This test uses a quantity with excessive decimal places (0.0123456789) + that is unlikely to be a valid step multiple for any market. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER QTY NOT STEP MULTIPLE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Use a quantity with excessive decimal places that won't be a step multiple + non_step_qty = "0.0123456789" + order_price = str(spot_config.price(0.96)) + + order_params = OrderBuilder().symbol(spot_config.symbol).buy().price(order_price).qty(non_step_qty).gtc().build() + + logger.info(f"Sending order with non-step-multiple qty: {non_step_qty}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + # If accepted, clean up + await spot_tester.client.cancel_order( + order_id=order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.05) + pytest.fail(f"Order with non-step qty should have been rejected, got: {order_id}") + except ApiException as e: + error_msg = str(e) + # Expect: error=CREATE_ORDER_OTHER_ERROR message='Order quantity X does not conform to base spacing Y' + assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" + assert ( + "does not conform to base spacing" in error_msg + ), f"Expected 'does not conform to base spacing' message, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT ORDER QTY NOT STEP MULTIPLE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_price_not_tick_multiple(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order with price not a multiple of tick size is rejected. + + Prices must be multiples of the market's tick size. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER PRICE NOT TICK MULTIPLE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Use a price with too many decimal places (beyond tick precision) + # Most markets have tick size like 0.01, so 0.001 precision would be invalid + non_tick_price = "250.123456789" + + order_params = ( + OrderBuilder().symbol(spot_config.symbol).buy().price(non_tick_price).qty(spot_config.min_qty).gtc().build() + ) + + logger.info(f"Sending order with non-tick-multiple price: {non_tick_price}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + # If accepted, clean up + await spot_tester.client.cancel_order( + order_id=order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.05) + pytest.fail(f"Order with non-tick-multiple price should have been rejected, got: {order_id}") + except ApiException as e: + error_msg = str(e) + # Expect: error=CREATE_ORDER_OTHER_ERROR message='Order price X does not conform to price spacing Y' + assert "CREATE_ORDER_OTHER_ERROR" in error_msg, f"Expected CREATE_ORDER_OTHER_ERROR, got: {e}" + assert ( + "does not conform to price spacing" in error_msg + ), f"Expected 'does not conform to price spacing' message, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT ORDER PRICE NOT TICK MULTIPLE TEST COMPLETED") + + +# ============================================================================ +# CANCEL ORDER VALIDATION TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_cancel_invalid_signature(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a cancel request with an invalid signature is rejected. + """ + logger.info("=" * 80) + logger.info("SPOT CANCEL INVALID SIGNATURE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # First create a valid order + order_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .buy() + .price(str(spot_config.price(0.96))) + .qty(spot_config.min_qty) + .gtc() + .build() + ) + + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"Created order: {order_id}") + + # Try to cancel with invalid signature + fake_signature = "0x" + "cd" * 65 + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + cancel_request = CancelOrderRequest( + orderId=order_id, + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + signature=fake_signature, + nonce=str(nonce), + expiresAfter=deadline, + ) + + logger.info("Sending cancel with invalid signature...") + + try: + response = await spot_tester.client.orders.cancel_order(cancel_order_request=cancel_request) + pytest.fail(f"Cancel with invalid signature should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e) + # Expect: error=CANCEL_ORDER_OTHER_ERROR message='Invalid signature: unable to recover signer from signature' + assert "CANCEL_ORDER_OTHER_ERROR" in error_msg, f"Expected CANCEL_ORDER_OTHER_ERROR, got: {e}" + assert ( + "Invalid signature: unable to recover signer from signature" in error_msg + ), f"Expected 'Invalid signature: unable to recover signer from signature' message, got: {e}" + logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + # Clean up - cancel with valid request + await spot_tester.client.cancel_order( + order_id=order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT CANCEL INVALID SIGNATURE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_cancel_reused_nonce(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a cancel request with a reused nonce is rejected. + + First creates and cancels an order with a specific nonce, then tries to + reuse that nonce for another cancel. + """ + logger.info("=" * 80) + logger.info("SPOT CANCEL REUSED NONCE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Step 1: Create first order and cancel it with a specific nonce + order_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .buy() + .price(str(spot_config.price(0.96))) + .qty(spot_config.min_qty) + .gtc() + .build() + ) + + first_order_id = await spot_tester.orders.create_limit(order_params) + assert first_order_id is not None, "Order creation should return order_id for GTC orders" + await spot_tester.wait.for_order_creation(first_order_id) + logger.info(f"Step 1: Created first order: {first_order_id}") + + first_nonce = spot_tester.get_next_nonce() + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + order_id=int(first_order_id), + client_order_id=0, + nonce=first_nonce, + deadline=first_deadline, + ) + + first_cancel_request = CancelOrderRequest( + orderId=first_order_id, + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + signature=first_signature, + nonce=str(first_nonce), + expiresAfter=first_deadline, + ) + + logger.info(f"Step 1: Cancelling first order with nonce: {first_nonce}") + await spot_tester.client.orders.cancel_order(cancel_order_request=first_cancel_request) + await asyncio.sleep(0.1) + logger.info("✅ First cancel succeeded") + + # Step 2: Create second order and try to cancel with reused nonce + second_order_id = await spot_tester.orders.create_limit(order_params) + assert second_order_id is not None, "Order creation should return order_id for GTC orders" + await spot_tester.wait.for_order_creation(second_order_id) + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + order_id=int(second_order_id), + client_order_id=0, + nonce=first_nonce, # Reuse the same nonce + deadline=reused_deadline, + ) + + reused_cancel_request = CancelOrderRequest( + orderId=second_order_id, + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + signature=reused_signature, + nonce=str(first_nonce), # Reuse the same nonce + expiresAfter=reused_deadline, + ) + + logger.info(f"Step 2: Trying to cancel with reused nonce: {first_nonce}") + + try: + response = await spot_tester.client.orders.cancel_order(cancel_order_request=reused_cancel_request) + pytest.fail(f"Cancel with reused nonce should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + # API should reject with 400 error - check for nonce-related keywords + assert ( + "400" in str(e) or "nonce" in error_msg or "invalid" in error_msg + ), f"Expected nonce rejection error, got: {e}" + logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + # Clean up - cancel with valid request + await spot_tester.client.cancel_order( + order_id=second_order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT CANCEL REUSED NONCE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_cancel_old_nonce(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a cancel request with an old nonce (nonce - 1) is rejected. + + First creates and cancels an order with a specific nonce, then tries to + use nonce-1 for another cancel. + """ + logger.info("=" * 80) + logger.info("SPOT CANCEL OLD NONCE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Step 1: Create first order and cancel it with a specific nonce + order_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .buy() + .price(str(spot_config.price(0.96))) + .qty(spot_config.min_qty) + .gtc() + .build() + ) + + first_order_id = await spot_tester.orders.create_limit(order_params) + assert first_order_id is not None, "Order creation should return order_id for GTC orders" + await spot_tester.wait.for_order_creation(first_order_id) + logger.info(f"Step 1: Created first order: {first_order_id}") + + first_nonce = spot_tester.get_next_nonce() + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + order_id=int(first_order_id), + client_order_id=0, + nonce=first_nonce, + deadline=first_deadline, + ) + + first_cancel_request = CancelOrderRequest( + orderId=first_order_id, + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + signature=first_signature, + nonce=str(first_nonce), + expiresAfter=first_deadline, + ) + + logger.info(f"Step 1: Cancelling first order with nonce: {first_nonce}") + await spot_tester.client.orders.cancel_order(cancel_order_request=first_cancel_request) + await asyncio.sleep(0.1) + logger.info("✅ First cancel succeeded") + + # Step 2: Create second order and try to cancel with nonce - 1 + second_order_id = await spot_tester.orders.create_limit(order_params) + assert second_order_id is not None, "Order creation should return order_id for GTC orders" + await spot_tester.wait.for_order_creation(second_order_id) + logger.info(f"Step 2: Created second order: {second_order_id}") + + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + order_id=int(second_order_id), + client_order_id=0, + nonce=old_nonce, # Use nonce - 1 + deadline=old_deadline, + ) + + old_cancel_request = CancelOrderRequest( + orderId=second_order_id, + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + signature=old_signature, + nonce=str(old_nonce), # Use nonce - 1 + expiresAfter=old_deadline, + ) + + logger.info(f"Step 2: Trying to cancel with old nonce (nonce-1): {old_nonce}") + + try: + response = await spot_tester.client.orders.cancel_order(cancel_order_request=old_cancel_request) + pytest.fail(f"Cancel with old nonce should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + # API should reject with 400 error - check for nonce-related keywords + assert ( + "400" in str(e) or "nonce" in error_msg or "invalid" in error_msg + ), f"Expected nonce rejection error, got: {e}" + logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + # Clean up - cancel with valid request + await spot_tester.client.cancel_order( + order_id=second_order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT CANCEL OLD NONCE TEST COMPLETED") + + +# ============================================================================ +# MASS CANCEL VALIDATION TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_mass_cancel_invalid_signature(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a mass cancel with an invalid signature is rejected. + """ + logger.info("=" * 80) + logger.info("SPOT MASS CANCEL INVALID SIGNATURE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Create a fake signature + fake_signature = "0x" + "ab" * 65 + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + mass_cancel_request = MassCancelRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + signature=fake_signature, + nonce=str(nonce), + expiresAfter=deadline, + ) + + logger.info("Sending mass cancel with invalid signature...") + + try: + response = await spot_tester.client.orders.cancel_all(mass_cancel_request) + pytest.fail(f"Mass cancel with invalid signature should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e) + # Expect: Invalid signature error + assert "Invalid signature" in error_msg or "CANCEL" in error_msg, f"Expected signature error, got: {e}" + logger.info(f"✅ Mass cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT MASS CANCEL INVALID SIGNATURE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_mass_cancel_expired_deadline(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a mass cancel with an expired deadline is rejected. + """ + logger.info("=" * 80) + logger.info("SPOT MASS CANCEL EXPIRED DEADLINE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Use an expired deadline (1 hour in the past) + expired_deadline = int(time.time()) - 3600 # 1 hour ago (in seconds) + nonce = spot_tester.get_next_nonce() + + sig_gen = spot_tester.client.signature_generator + signature = sig_gen.sign_mass_cancel( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + nonce=nonce, + deadline=expired_deadline, + ) + + mass_cancel_request = MassCancelRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + signature=signature, + nonce=str(nonce), + expiresAfter=expired_deadline, + ) + + logger.info(f"Sending mass cancel with expired deadline: {expired_deadline}") + + try: + response = await spot_tester.client.orders.cancel_all(mass_cancel_request) + pytest.fail(f"Mass cancel with expired deadline should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + # API should reject with 400 error - check for deadline-related keywords + assert ( + "400" in str(e) or "deadline" in error_msg or "expired" in error_msg + ), f"Expected deadline rejection error, got: {e}" + logger.info(f"✅ Mass cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT MASS CANCEL EXPIRED DEADLINE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_mass_cancel_reused_nonce(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a mass cancel with a reused nonce is rejected. + + First performs a successful mass cancel with a specific nonce, then + tries to reuse that nonce. + """ + logger.info("=" * 80) + logger.info("SPOT MASS CANCEL REUSED NONCE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Step 1: Perform a valid mass cancel using the SDK's nonce generator + # This establishes a nonce baseline in the API's nonce tracker + first_nonce = spot_tester.client.get_next_nonce() + first_deadline = int(time.time()) + 60 # 1 minute from now + + sig_gen = spot_tester.client.signature_generator + first_signature = sig_gen.sign_mass_cancel( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + nonce=first_nonce, + deadline=first_deadline, + ) + + first_mass_cancel_request = MassCancelRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + signature=first_signature, + nonce=str(first_nonce), + expiresAfter=first_deadline, + ) + + logger.info(f"Step 1: Performing mass cancel with nonce: {first_nonce}") + await spot_tester.client.orders.cancel_all(first_mass_cancel_request) + logger.info("✅ First mass cancel succeeded (established nonce baseline)") + + # Step 2: Try to reuse the SAME nonce - this should be rejected + reused_deadline = int(time.time()) + 60 # 1 minute from now + reused_signature = sig_gen.sign_mass_cancel( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + nonce=first_nonce, # Reuse the same nonce + deadline=reused_deadline, + ) + + reused_mass_cancel_request = MassCancelRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + signature=reused_signature, + nonce=str(first_nonce), # Reuse the same nonce + expiresAfter=reused_deadline, + ) + + logger.info(f"Step 2: Trying mass cancel with reused nonce: {first_nonce}") + + try: + response = await spot_tester.client.orders.cancel_all(reused_mass_cancel_request) + pytest.fail(f"Mass cancel with reused nonce should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + # API should reject with 400 error - check for nonce-related keywords + assert ( + "400" in str(e) or "nonce" in error_msg or "invalid" in error_msg + ), f"Expected nonce rejection error, got: {e}" + logger.info(f"✅ Mass cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT MASS CANCEL REUSED NONCE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_mass_cancel_old_nonce(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a mass cancel with an old nonce (nonce - 1) is rejected. + + First performs a successful mass cancel with a specific nonce, then + tries to use nonce-1. + """ + logger.info("=" * 80) + logger.info("SPOT MASS CANCEL OLD NONCE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Step 1: Perform a valid mass cancel using the SDK's nonce generator + # This establishes a nonce baseline in the API's nonce tracker + first_nonce = spot_tester.client.get_next_nonce() + first_deadline = int(time.time()) + 60 # 1 minute from now + + sig_gen = spot_tester.client.signature_generator + first_signature = sig_gen.sign_mass_cancel( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + nonce=first_nonce, + deadline=first_deadline, + ) + + first_mass_cancel_request = MassCancelRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + signature=first_signature, + nonce=str(first_nonce), + expiresAfter=first_deadline, + ) + + logger.info(f"Step 1: Performing mass cancel with nonce: {first_nonce}") + await spot_tester.client.orders.cancel_all(first_mass_cancel_request) + logger.info("✅ First mass cancel succeeded (established nonce baseline)") + + # Step 2: Try to use nonce - 1 (which is definitely old now) + old_nonce = first_nonce - 1 + old_deadline = int(time.time()) + 60 # 1 minute from now + old_signature = sig_gen.sign_mass_cancel( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + nonce=old_nonce, + deadline=old_deadline, + ) + + old_mass_cancel_request = MassCancelRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + signature=old_signature, + nonce=str(old_nonce), + expiresAfter=old_deadline, + ) + + logger.info(f"Step 2: Trying mass cancel with old nonce (nonce-1): {old_nonce}") + + try: + response = await spot_tester.client.orders.cancel_all(old_mass_cancel_request) + pytest.fail(f"Mass cancel with old nonce should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + # API should reject with 400 error - check for nonce-related keywords + assert ( + "400" in str(e) or "nonce" in error_msg or "invalid" in error_msg + ), f"Expected nonce rejection error, got: {e}" + logger.info(f"✅ Mass cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT MASS CANCEL OLD NONCE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_cancel_wrong_signer(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a cancel request signed by a different wallet is rejected. + + The API should verify that the signer has permission to cancel orders + on the specified account. + """ + logger.info("=" * 80) + logger.info("SPOT CANCEL WRONG SIGNER TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # First create a valid order + order_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .buy() + .price(str(spot_config.price(0.96))) + .qty(spot_config.min_qty) + .gtc() + .build() + ) + + order_id = await spot_tester.orders.create_limit(order_params) + assert order_id is not None, "Order creation should return order_id for GTC orders" + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"Created order: {order_id}") + + # Create a different signer (wrong wallet) + wrong_private_key = "0x" + "ab" * 32 # Random private key + wrong_config = TradingConfig( + api_url=spot_tester.client.config.api_url, + chain_id=spot_tester.client.config.chain_id, + owner_wallet_address=spot_tester.client.config.owner_wallet_address, + private_key=wrong_private_key, + account_id=spot_tester.account_id, + ) + wrong_signer = SignatureGenerator(wrong_config) + + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + # Sign cancel request with the wrong private key + wrong_signature = wrong_signer.sign_cancel_order_spot( + account_id=spot_tester.account_id, # Same account + market_id=SPOT_MARKET_ID, + order_id=int(order_id), + client_order_id=0, + nonce=nonce, + deadline=deadline, + ) + + cancel_request = CancelOrderRequest( + orderId=order_id, + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + signature=wrong_signature, + nonce=str(nonce), + expiresAfter=deadline, + ) + + logger.info(f"Sending cancel signed by wrong wallet: {wrong_signer.signer_wallet_address}") + + try: + response = await spot_tester.client.orders.cancel_order(cancel_order_request=cancel_request) + pytest.fail(f"Cancel from unauthorized signer should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + # API should reject with 400 error - check for unauthorized/signature-related keywords + assert ( + "400" in str(e) or "unauthorized" in error_msg or "signature" in error_msg or "permission" in error_msg + ), f"Expected unauthorized signer rejection error, got: {e}" + logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + # Clean up - cancel with valid request + await spot_tester.client.cancel_order( + order_id=order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT CANCEL WRONG SIGNER TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_mass_cancel_wrong_signer(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a mass cancel request signed by a different wallet is rejected. + + The API should verify that the signer has permission to cancel orders + on the specified account. + """ + logger.info("=" * 80) + logger.info("SPOT MASS CANCEL WRONG SIGNER TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # First create a valid order + order_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .buy() + .price(str(spot_config.price(0.96))) + .qty(spot_config.min_qty) + .gtc() + .build() + ) + + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"Created order: {order_id}") + + # Create a different signer (wrong wallet) + wrong_private_key = "0x" + "cd" * 32 # Random private key + wrong_config = TradingConfig( + api_url=spot_tester.client.config.api_url, + chain_id=spot_tester.client.config.chain_id, + owner_wallet_address=spot_tester.client.config.owner_wallet_address, + private_key=wrong_private_key, + account_id=spot_tester.account_id, + ) + wrong_signer = SignatureGenerator(wrong_config) + + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + # Sign mass cancel request with the wrong private key + wrong_signature = wrong_signer.sign_mass_cancel( + account_id=spot_tester.account_id, # Same account + market_id=SPOT_MARKET_ID, + nonce=nonce, + deadline=deadline, + ) + + mass_cancel_request = MassCancelRequest( + accountId=spot_tester.account_id, + symbol=spot_config.symbol, + signature=wrong_signature, + nonce=str(nonce), + expiresAfter=deadline, + ) + + logger.info(f"Sending mass cancel signed by wrong wallet: {wrong_signer.signer_wallet_address}") + + try: + response = await spot_tester.client.orders.cancel_all(mass_cancel_request) + pytest.fail(f"Mass cancel from unauthorized signer should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + # API should reject with 400 error - check for unauthorized/signature-related keywords + assert ( + "400" in str(e) or "unauthorized" in error_msg or "signature" in error_msg or "permission" in error_msg + ), f"Expected unauthorized signer rejection error, got: {e}" + logger.info(f"✅ Mass cancel rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + # Clean up - cancel with valid request + await spot_tester.client.cancel_order( + order_id=order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + logger.info("✅ SPOT MASS CANCEL WRONG SIGNER TEST COMPLETED") + + +# ============================================================================ +# REQUEST FIELD VALIDATION TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_invalid_exchange_id(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order with an invalid exchangeId is rejected. + + The API should validate that exchangeId is a positive number. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER INVALID EXCHANGE ID TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Build order request with invalid exchangeId + price = str(spot_config.price(0.96)) + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, + inputs=inputs, + deadline=deadline, + nonce=nonce, + ) + + # Create request with invalid exchangeId (0 or negative) + order_request = CreateOrderRequest( + exchangeId=0, # Invalid - must be positive + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + isBuy=True, + limitPx=price, + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + signature=signature, + nonce=str(nonce), + expiresAfter=deadline, + signerWallet=sig_gen.signer_wallet_address, + ) + + logger.info("Sending order with exchangeId=0...") + + try: + response = await spot_tester.client.orders.create_order(order_request) + pytest.fail(f"Order with invalid exchangeId should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e) + assert ( + "exchangeId must be a positive integer" in error_msg + ), f"Expected 'exchangeId must be a positive integer' error, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + logger.info("✅ SPOT ORDER INVALID EXCHANGE ID TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_invalid_symbol(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order with an unrecognized symbol is rejected. + + The API should validate that the symbol maps to a valid market. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER INVALID SYMBOL TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + price = str(spot_config.price(0.96)) + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, + inputs=inputs, + deadline=deadline, + nonce=nonce, + ) + + # Create request with invalid symbol + order_request = CreateOrderRequest( + exchangeId=spot_tester.client.config.dex_id, + symbol="INVALIDXYZ", # Non-existent symbol + accountId=spot_tester.account_id, + isBuy=True, + limitPx=price, + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + signature=signature, + nonce=str(nonce), + expiresAfter=deadline, + signerWallet=sig_gen.signer_wallet_address, + ) + + logger.info("Sending order with invalid symbol 'INVALIDXYZ'...") + + try: + response = await spot_tester.client.orders.create_order(order_request) + pytest.fail(f"Order with invalid symbol should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + assert ( + "symbol" in error_msg or "unrecognized" in error_msg or "CREATE_ORDER_OTHER_ERROR" in str(e) + ), f"Expected symbol validation error, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + logger.info("✅ SPOT ORDER INVALID SYMBOL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_missing_signature(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order without a signature is rejected. + + The API should validate that signature is required. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER MISSING SIGNATURE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + price = str(spot_config.price(0.96)) + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + sig_gen = spot_tester.client.signature_generator + + # Create request without signature (empty string) + order_request = CreateOrderRequest( + exchangeId=spot_tester.client.config.dex_id, + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + isBuy=True, + limitPx=price, + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + signature="", # Empty signature + nonce=str(nonce), + expiresAfter=deadline, + signerWallet=sig_gen.signer_wallet_address, + ) + + logger.info("Sending order with empty signature...") + + try: + response = await spot_tester.client.orders.create_order(order_request) + pytest.fail(f"Order without signature should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + assert "signature" in error_msg or "CREATE_ORDER_OTHER_ERROR" in str( + e + ), f"Expected signature validation error, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + logger.info("✅ SPOT ORDER MISSING SIGNATURE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_missing_nonce(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order without a nonce is rejected. + + The API should validate that nonce is required. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER MISSING NONCE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + price = str(spot_config.price(0.96)) + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, + inputs=inputs, + deadline=deadline, + nonce=nonce, + ) + + # Create request without nonce (empty string) + order_request = CreateOrderRequest( + exchangeId=spot_tester.client.config.dex_id, + symbol=spot_config.symbol, + accountId=spot_tester.account_id, + isBuy=True, + limitPx=price, + qty=spot_config.min_qty, + orderType=OrderType.LIMIT, + timeInForce=TimeInForce.GTC, + signature=signature, + nonce="", # Empty nonce + expiresAfter=deadline, + signerWallet=sig_gen.signer_wallet_address, + ) + + logger.info("Sending order with empty nonce...") + + try: + response = await spot_tester.client.orders.create_order(order_request) + pytest.fail(f"Order without nonce should have been rejected, got: {response}") + except ApiException as e: + error_msg = str(e).lower() + assert "nonce" in error_msg or "CREATE_ORDER_OTHER_ERROR" in str( + e + ), f"Expected nonce validation error, got: {e}" + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + logger.info("✅ SPOT ORDER MISSING NONCE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_invalid_time_in_force(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that an order with an invalid timeInForce is rejected. + + The API should validate that timeInForce is one of: IOC, GTC. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER INVALID TIME IN FORCE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + price = str(spot_config.price(0.96)) + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + nonce = spot_tester.get_next_nonce() + + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, + inputs=inputs, + deadline=deadline, + nonce=nonce, + ) + + # Create request dict with invalid timeInForce (bypass enum validation) + order_dict = { + "exchangeId": spot_tester.client.config.dex_id, + "symbol": spot_config.symbol, + "accountId": spot_tester.account_id, + "isBuy": True, + "limitPx": price, + "qty": spot_config.min_qty, + "orderType": "LIMIT", + "timeInForce": "INVALID_TIF", # Invalid value + "signature": signature, + "nonce": str(nonce), + "expiresAfter": deadline, + "signerWallet": sig_gen.signer_wallet_address, + } + + logger.info("Sending order with invalid timeInForce 'INVALID_TIF'...") + + try: + # Use raw HTTP request to bypass SDK validation + 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 with invalid timeInForce should have been rejected") + response_text = await resp.text() + assert ( + "timeInForce" in response_text.lower() or resp.status == 400 + ), f"Expected timeInForce 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: + # If we can't make raw request, the SDK validation caught it + logger.info(f"✅ Order rejected (SDK or API validation): {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + logger.info("✅ SPOT ORDER INVALID TIME IN FORCE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_order_missing_expiration(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a spot order without expiresAfter is rejected. + + The API should validate that expiresAfter is required for spot orders. + """ + logger.info("=" * 80) + logger.info("SPOT ORDER MISSING EXPIRATION TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + price = str(spot_config.price(0.96)) + nonce = spot_tester.get_next_nonce() + deadline = int(time.time()) + 60 # 1 minute from now (in seconds) + + 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( + account_id=spot_tester.account_id, + market_id=SPOT_MARKET_ID, + exchange_id=spot_tester.client.config.dex_id, + counterparty_account_ids=[], + order_type=6, + inputs=inputs, + deadline=deadline, + nonce=nonce, + ) + + # Create request dict without expiresAfter + order_dict = { + "exchangeId": spot_tester.client.config.dex_id, + "symbol": spot_config.symbol, + "accountId": spot_tester.account_id, + "isBuy": True, + "limitPx": price, + "qty": spot_config.min_qty, + "orderType": "LIMIT", + "timeInForce": "GTC", + "signature": signature, + "nonce": str(nonce), + # expiresAfter intentionally omitted + "signerWallet": sig_gen.signer_wallet_address, + } + + logger.info("Sending order without expiresAfter...") + + 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") + 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}" + logger.info(f"✅ Order rejected as expected: HTTP {resp.status}") + logger.info(f" Error: {response_text[:150]}") + except ApiException as e: + logger.info(f"✅ Order rejected (SDK or API validation): {type(e).__name__}") + logger.info(f" Error: {str(e)[:150]}") + + logger.info("✅ SPOT ORDER MISSING EXPIRATION TEST COMPLETED") diff --git a/tests/test_spot/test_balance_verification.py b/tests/test_spot/test_balance_verification.py new file mode 100644 index 00000000..0c446c57 --- /dev/null +++ b/tests/test_spot/test_balance_verification.py @@ -0,0 +1,364 @@ +""" +Spot Balance Verification Tests + +Tests for verifying balance changes after spot trades: +- Balance update after buy +- Balance update after sell +- WebSocket balance matches REST +- Maker/taker balance consistency + +Note: Spot trading has ZERO fees, so we can verify exact balance changes: +- ETH change = trade quantity +- RUSD change = trade quantity * execution price +""" + +import asyncio +import logging +from decimal import Decimal + +import pytest + +from sdk.open_api.models import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import OrderBuilder +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + + +async def get_account_balances(tester: ReyaTester) -> dict: + """Get current balances for a specific account.""" + balances = await tester.client.get_account_balances() + # Filter by the tester's account_id + account_balances = [b for b in balances if b.account_id == tester.account_id] + return {b.asset: Decimal(b.real_balance) for b in account_balances} + + +@pytest.mark.spot +@pytest.mark.balance +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_balance_update_after_buy( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test balance changes correctly after spot buy. + + This test requires a controlled environment to verify exact balance changes + between our maker and taker accounts. When external liquidity exists, + we skip to avoid unpredictable balance changes. + + When taker buys ETH: + - Taker: ETH balance increases, RUSD balance decreases + - Maker: ETH balance decreases, RUSD balance increases + + Flow: + 1. Check for external liquidity - skip if present + 2. Record initial balances + 3. Maker places sell order + 4. Taker buys with IOC + 5. Verify balance changes + """ + logger.info("=" * 80) + logger.info(f"SPOT BALANCE UPDATE AFTER BUY TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists - this test needs controlled environment for balance verification + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping balance verification test: external liquidity exists. " + "This test requires a controlled environment to verify exact balance changes." + ) + + # Record initial balances + maker_balances_before = await get_account_balances(maker_tester) + taker_balances_before = await get_account_balances(taker_tester) + + logger.info( + f"Maker initial: ETH={maker_balances_before.get('ETH', 0)}, RUSD={maker_balances_before.get('RUSD', 0)}" + ) + logger.info( + f"Taker initial: ETH={taker_balances_before.get('ETH', 0)}, RUSD={taker_balances_before.get('RUSD', 0)}" + ) + + # Maker places GTC sell order + maker_price = spot_config.price(1.04) # High price to avoid other matches + + maker_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() + + logger.info(f"Maker placing GTC sell: {spot_config.min_qty} @ ${maker_price:.2f}") + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id}") + + # Taker buys with IOC + taker_price = maker_price + + taker_params = OrderBuilder.from_config(spot_config).buy().at_price(1.04).ioc().build() + + logger.info(f"Taker placing IOC buy: {spot_config.min_qty} @ ${taker_price:.2f}") + await taker_tester.orders.create_limit(taker_params) + + # Wait for trade to settle + await asyncio.sleep(0.05) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + + # Wait for indexer to update balances (REST API may lag behind WebSocket) + await asyncio.sleep(1.0) + + # Get balances after trade + maker_balances_after = await get_account_balances(maker_tester) + taker_balances_after = await get_account_balances(taker_tester) + + logger.info(f"Maker after: ETH={maker_balances_after.get('ETH', 0)}, RUSD={maker_balances_after.get('RUSD', 0)}") + logger.info(f"Taker after: ETH={taker_balances_after.get('ETH', 0)}, RUSD={taker_balances_after.get('RUSD', 0)}") + + # Calculate changes + qty = Decimal(spot_config.min_qty) + execution_price = Decimal(str(maker_price)) # Trade executes at maker's price + expected_rusd_change = qty * execution_price + + # Maker sold ETH, received RUSD + maker_eth_change = maker_balances_after.get("ETH", Decimal(0)) - maker_balances_before.get("ETH", Decimal(0)) + maker_rusd_change = maker_balances_after.get("RUSD", Decimal(0)) - maker_balances_before.get("RUSD", Decimal(0)) + + # Taker bought ETH, paid RUSD + taker_eth_change = taker_balances_after.get("ETH", Decimal(0)) - taker_balances_before.get("ETH", Decimal(0)) + taker_rusd_change = taker_balances_after.get("RUSD", Decimal(0)) - taker_balances_before.get("RUSD", Decimal(0)) + + logger.info(f"Maker changes: ETH={maker_eth_change}, RUSD={maker_rusd_change}") + logger.info(f"Taker changes: ETH={taker_eth_change}, RUSD={taker_rusd_change}") + logger.info(f"Expected: qty={qty}, price={execution_price}, rusd_change={expected_rusd_change}") + + # Verify EXACT balance changes (spot has zero fees) + # Maker sold ETH: ETH decreases by qty, RUSD increases by qty * price + assert maker_eth_change == -qty, f"Maker ETH change should be exactly -{qty}, got: {maker_eth_change}" + assert ( + maker_rusd_change == expected_rusd_change + ), f"Maker RUSD change should be exactly +{expected_rusd_change}, got: {maker_rusd_change}" + + # Taker bought ETH: ETH increases by qty, RUSD decreases by qty * price + assert taker_eth_change == qty, f"Taker ETH change should be exactly +{qty}, got: {taker_eth_change}" + assert ( + taker_rusd_change == -expected_rusd_change + ), f"Taker RUSD change should be exactly -{expected_rusd_change}, got: {taker_rusd_change}" + + logger.info("✅ EXACT balance changes verified (zero fees confirmed)") + logger.info("✅ SPOT BALANCE UPDATE AFTER BUY TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.balance +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_balance_update_after_sell( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test balance changes correctly after spot sell. + + This test requires a controlled environment to verify exact balance changes + between our maker and taker accounts. When external liquidity exists, + we skip to avoid unpredictable balance changes. + + When maker sells ETH (maker has more ETH): + - Maker: ETH balance decreases, RUSD balance increases + - Taker: ETH balance increases, RUSD balance decreases + + Flow: + 1. Check for external liquidity - skip if present + 2. Record initial balances + 3. Maker places sell order + 4. Taker buys with IOC + 5. Verify balance changes + """ + logger.info("=" * 80) + logger.info(f"SPOT BALANCE UPDATE AFTER SELL TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists - this test needs controlled environment for balance verification + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping balance verification test: external liquidity exists. " + "This test requires a controlled environment to verify exact balance changes." + ) + + # Record initial balances + maker_balances_before = await get_account_balances(maker_tester) + taker_balances_before = await get_account_balances(taker_tester) + + logger.info( + f"Maker initial: ETH={maker_balances_before.get('ETH', 0)}, RUSD={maker_balances_before.get('RUSD', 0)}" + ) + logger.info( + f"Taker initial: ETH={taker_balances_before.get('ETH', 0)}, RUSD={taker_balances_before.get('RUSD', 0)}" + ) + + # Maker places GTC sell order (maker has more ETH) + maker_price = spot_config.price(1.04) # High price to avoid other matches + + maker_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() + + logger.info(f"Maker placing GTC sell: {spot_config.min_qty} @ ${maker_price:.2f}") + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id}") + + # Taker buys with IOC (taker has more RUSD) + taker_price = maker_price + + taker_params = OrderBuilder.from_config(spot_config).buy().at_price(1.04).ioc().build() + + logger.info(f"Taker placing IOC buy: {spot_config.min_qty} @ ${taker_price:.2f}") + await taker_tester.orders.create_limit(taker_params) + + # Wait for trade to settle + await asyncio.sleep(0.05) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + + # Wait for indexer to update balances (REST API may lag behind WebSocket) + await asyncio.sleep(1.0) + + # Get balances after trade + maker_balances_after = await get_account_balances(maker_tester) + taker_balances_after = await get_account_balances(taker_tester) + + logger.info(f"Maker after: ETH={maker_balances_after.get('ETH', 0)}, RUSD={maker_balances_after.get('RUSD', 0)}") + logger.info(f"Taker after: ETH={taker_balances_after.get('ETH', 0)}, RUSD={taker_balances_after.get('RUSD', 0)}") + + # Calculate changes + qty = Decimal(spot_config.min_qty) + execution_price = Decimal(str(maker_price)) # Trade executes at maker's price + expected_rusd_change = qty * execution_price + + # Maker sold ETH, received RUSD + maker_eth_change = maker_balances_after.get("ETH", Decimal(0)) - maker_balances_before.get("ETH", Decimal(0)) + maker_rusd_change = maker_balances_after.get("RUSD", Decimal(0)) - maker_balances_before.get("RUSD", Decimal(0)) + + # Taker bought ETH, paid RUSD + taker_eth_change = taker_balances_after.get("ETH", Decimal(0)) - taker_balances_before.get("ETH", Decimal(0)) + taker_rusd_change = taker_balances_after.get("RUSD", Decimal(0)) - taker_balances_before.get("RUSD", Decimal(0)) + + logger.info(f"Maker changes: ETH={maker_eth_change}, RUSD={maker_rusd_change}") + logger.info(f"Taker changes: ETH={taker_eth_change}, RUSD={taker_rusd_change}") + logger.info(f"Expected: qty={qty}, price={execution_price}, rusd_change={expected_rusd_change}") + + # Verify EXACT balance changes (spot has zero fees) + # Maker sold ETH: ETH decreases by qty, RUSD increases by qty * price + assert maker_eth_change == -qty, f"Maker ETH change should be exactly -{qty}, got: {maker_eth_change}" + assert ( + maker_rusd_change == expected_rusd_change + ), f"Maker RUSD change should be exactly +{expected_rusd_change}, got: {maker_rusd_change}" + + # Taker bought ETH: ETH increases by qty, RUSD decreases by qty * price + assert taker_eth_change == qty, f"Taker ETH change should be exactly +{qty}, got: {taker_eth_change}" + assert ( + taker_rusd_change == -expected_rusd_change + ), f"Taker RUSD change should be exactly -{expected_rusd_change}, got: {taker_rusd_change}" + + logger.info("✅ EXACT balance changes verified (zero fees confirmed)") + logger.info("✅ SPOT BALANCE UPDATE AFTER SELL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.balance +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_balance_maker_taker_consistency( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test that maker and taker balances sum correctly (conservation of value). + + This test requires a controlled environment to verify exact balance conservation + between our maker and taker accounts. When external liquidity exists, + we skip to avoid unpredictable balance changes. + + Since spot trading has ZERO fees, the total ETH and RUSD across both + accounts should be EXACTLY conserved. + + Flow: + 1. Check for external liquidity - skip if present + 2. Record total balances before trade + 3. Execute trade + 4. Verify total balances are exactly conserved (no fee tolerance needed) + """ + logger.info("=" * 80) + logger.info(f"SPOT BALANCE MAKER/TAKER CONSISTENCY TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists - this test needs controlled environment for balance verification + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping balance consistency test: external liquidity exists. " + "This test requires a controlled environment to verify exact balance conservation." + ) + + # Record initial balances + maker_balances_before = await get_account_balances(maker_tester) + taker_balances_before = await get_account_balances(taker_tester) + + total_eth_before = maker_balances_before.get("ETH", Decimal(0)) + taker_balances_before.get("ETH", Decimal(0)) + total_rusd_before = maker_balances_before.get("RUSD", Decimal(0)) + taker_balances_before.get("RUSD", Decimal(0)) + + logger.info(f"Total before: ETH={total_eth_before}, RUSD={total_rusd_before}") + + # Execute a trade + _ = spot_config.price(0.97) # maker_price - calculated for reference + + 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(0.05) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + + # Wait for indexer to update balances (REST API may lag behind WebSocket) + await asyncio.sleep(1.0) + + # Get balances after trade + maker_balances_after = await get_account_balances(maker_tester) + taker_balances_after = await get_account_balances(taker_tester) + + total_eth_after = maker_balances_after.get("ETH", Decimal(0)) + taker_balances_after.get("ETH", Decimal(0)) + total_rusd_after = maker_balances_after.get("RUSD", Decimal(0)) + taker_balances_after.get("RUSD", Decimal(0)) + + logger.info(f"Total after: ETH={total_eth_after}, RUSD={total_rusd_after}") + + # Calculate differences + eth_diff = total_eth_after - total_eth_before + rusd_diff = total_rusd_after - total_rusd_before + + logger.info(f"ETH difference: {eth_diff}") + logger.info(f"RUSD difference: {rusd_diff}") + + # Spot has ZERO fees - both ETH and RUSD should be EXACTLY conserved + assert eth_diff == Decimal(0), f"ETH not exactly conserved (zero fees expected): diff={eth_diff}" + logger.info("✅ ETH exactly conserved (zero fees)") + + assert rusd_diff == Decimal(0), f"RUSD not exactly conserved (zero fees expected): diff={rusd_diff}" + logger.info("✅ RUSD exactly conserved (zero fees)") + + logger.info("✅ SPOT BALANCE MAKER/TAKER CONSISTENCY TEST COMPLETED") diff --git a/tests/test_spot/test_error_handling.py b/tests/test_spot/test_error_handling.py new file mode 100644 index 00000000..7e2d0a98 --- /dev/null +++ b/tests/test_spot/test_error_handling.py @@ -0,0 +1,270 @@ +""" +Spot Error Handling & Edge Cases Tests + +Tests for error handling and edge cases: +- Invalid symbol +- Invalid account ID +- Invalid signature +- Expired nonce +- Zero quantity +- Negative price +""" + +import asyncio +import logging + +import pytest +from eth_abi.exceptions import EncodingError + +from sdk.open_api.exceptions import ApiException +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import OrderBuilder +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + + +@pytest.mark.spot +@pytest.mark.error +@pytest.mark.asyncio +async def test_spot_invalid_symbol(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test order with invalid symbol is rejected. + + Flow: + 1. Attempt to place order with non-existent symbol + 2. Verify error response + """ + logger.info("=" * 80) + logger.info("SPOT INVALID SYMBOL TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Use an invalid symbol + invalid_symbol = "INVALIDRUSD" + + order_params = ( + OrderBuilder() + .symbol(invalid_symbol) + .buy() + .price(str(spot_config.oracle_price)) + .qty(spot_config.min_qty) + .gtc() + .build() + ) + + logger.info(f"Attempting to place order with invalid symbol: {invalid_symbol}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + logger.info(f"Order unexpectedly accepted: {order_id}") + # If accepted, clean up + await spot_tester.client.cancel_order( + order_id=order_id, symbol=invalid_symbol, account_id=spot_tester.account_id + ) + except (ApiException, ValueError) as e: + # SDK validates symbol locally and raises ValueError if not found + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:100]}") + + logger.info("✅ SPOT INVALID SYMBOL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.error +@pytest.mark.asyncio +async def test_spot_zero_quantity(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test order with zero quantity is rejected. + + Flow: + 1. Attempt to place order with qty=0 + 2. Verify error response + """ + logger.info("=" * 80) + logger.info("SPOT ZERO QUANTITY TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + order_params = ( + OrderBuilder.from_config(spot_config).buy().price(str(spot_config.oracle_price)).qty("0").gtc().build() + ) + + logger.info("Attempting to place order with zero quantity") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + logger.info(f"Order unexpectedly accepted: {order_id}") + except ApiException as e: + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:100]}") + + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT ZERO QUANTITY TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.error +@pytest.mark.asyncio +async def test_spot_negative_price(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test order with negative price is rejected. + + Flow: + 1. Attempt to place order with negative price + 2. Verify error response + """ + logger.info("=" * 80) + logger.info("SPOT NEGATIVE PRICE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + order_params = OrderBuilder.from_config(spot_config).buy().price("-100").gtc().build() + + logger.info("Attempting to place order with negative price") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + logger.info(f"Order unexpectedly accepted: {order_id}") + except ApiException as e: + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:100]}") + except EncodingError as e: + # eth_abi raises ValueOutOfBounds (subclass of EncodingError) for negative prices + logger.info(f"✅ Order rejected as expected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:100]}") + + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT NEGATIVE PRICE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.error +@pytest.mark.asyncio +async def test_spot_very_small_quantity(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test order with very small quantity (below minimum). + + Flow: + 1. Attempt to place order with extremely small quantity + 2. Verify behavior (may be rejected or accepted based on market rules) + """ + logger.info("=" * 80) + logger.info("SPOT VERY SMALL QUANTITY TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Very small quantity - may be below minimum + small_qty = "0.0000001" + + order_params = ( + OrderBuilder().symbol(spot_config.symbol).buy().price(str(spot_config.price(0.96))).qty(small_qty).gtc().build() + ) + + logger.info(f"Attempting to place order with very small quantity: {small_qty}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + logger.info(f"Order accepted: {order_id}") + # Clean up if accepted + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + except ApiException as e: + logger.info(f"✅ Order rejected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:100]}") + + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT VERY SMALL QUANTITY TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.error +@pytest.mark.asyncio +async def test_spot_very_large_quantity(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test order with very large quantity (may exceed balance). + + Flow: + 1. Attempt to place order with quantity larger than balance + 2. Verify behavior (may be rejected for insufficient balance) + """ + logger.info("=" * 80) + logger.info("SPOT VERY LARGE QUANTITY TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Very large quantity - likely exceeds balance + large_qty = "1000000" + + order_params = ( + OrderBuilder().symbol(spot_config.symbol).buy().price(str(spot_config.price(0.96))).qty(large_qty).gtc().build() + ) + + logger.info(f"Attempting to place order with very large quantity: {large_qty}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + logger.info(f"Order accepted: {order_id}") + # Clean up if accepted + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + except ApiException as e: + logger.info(f"✅ Order rejected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:100]}") + + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT VERY LARGE QUANTITY TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.error +@pytest.mark.asyncio +async def test_spot_extreme_price(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test order with extreme price values. + + Flow: + 1. Attempt to place order with extremely high price + 2. Verify behavior + """ + logger.info("=" * 80) + logger.info("SPOT EXTREME PRICE TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Extremely high price + extreme_price = "999999999999" + + order_params = OrderBuilder.from_config(spot_config).buy().price(extreme_price).gtc().build() + + logger.info(f"Attempting to place order with extreme price: {extreme_price}") + + try: + order_id = await spot_tester.orders.create_limit(order_params) + logger.info(f"Order accepted: {order_id}") + # Clean up if accepted + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + except ApiException as e: + logger.info(f"✅ Order rejected: {type(e).__name__}") + logger.info(f" Error: {str(e)[:100]}") + + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT EXTREME PRICE TEST COMPLETED") diff --git a/tests/test_spot/test_gtc_orders.py b/tests/test_spot/test_gtc_orders.py new file mode 100644 index 00000000..f9d2758b --- /dev/null +++ b/tests/test_spot/test_gtc_orders.py @@ -0,0 +1,532 @@ +""" +Spot GTC (Good-Till-Cancelled) Order Tests + +Tests for GTC order behavior including: +- Full fill when matching liquidity exists +- Partial fill with remainder on book +- No match - order added to book +- Price-time priority (FIFO) +- Best price first matching +- Client order ID tracking + +These tests support both empty and non-empty order books: +- When external liquidity exists, tests use it instead of providing their own +- When no external liquidity exists, tests provide maker liquidity as before +- Execution assertions are flexible to handle order book changes +""" + +from typing import Optional + +import asyncio +import logging +import random +from decimal import Decimal + +import pytest + +from sdk.open_api.models import OrderStatus +from sdk.open_api.models.depth import Depth +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import OrderBuilder +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + + +@pytest.mark.spot +@pytest.mark.gtc +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_gtc_full_fill(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): + """ + Test GTC order fully filled immediately when matching liquidity exists. + + Supports both empty and non-empty order books: + - If external bid liquidity exists, taker sells into it + - If no external liquidity, maker provides bid liquidity first + + Flow: + 1. Check for external bid liquidity + 2. If needed, maker places GTC buy order + 3. Taker places GTC sell order at crossing price + 4. Verify execution occurred + """ + logger.info("=" * 80) + logger.info(f"SPOT GTC FULL FILL TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + maker_order_id: Optional[str] = None + fill_price: Decimal + + # Determine liquidity source - check both bid and ask + usable_bid_price = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid_price is not None: + # External bids exist - taker sells into them + fill_price = usable_bid_price + logger.info(f"Using external bid liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).gtc().build() + logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${fill_price:.2f}") + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker order sent: {taker_order_id}") + elif usable_ask_price is not None: + # External asks exist - taker buys from them + fill_price = usable_ask_price + logger.info(f"Using external ask liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).gtc().build() + logger.info(f"Taker placing GTC buy: {spot_config.min_qty} @ ${fill_price:.2f}") + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker order sent: {taker_order_id}") + else: + # No external liquidity - maker provides liquidity, taker fills + maker_price = spot_config.price(0.99) + fill_price = Decimal(str(maker_price)) + + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + logger.info(f"Maker placing GTC buy: {spot_config.min_qty} @ ${fill_price:.2f}") + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id}") + + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).gtc().build() + logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${fill_price:.2f}") + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker order sent: {taker_order_id}") + + # Wait for matching + await asyncio.sleep(0.1) + + # Verify taker order is filled + await taker_tester.wait.for_order_state(taker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Taker order filled") + + # Verify maker order is filled (if we placed one) + if maker_order_id: + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Maker order filled") + + # Verify no open orders remain from our accounts + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT GTC FULL FILL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.gtc +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_gtc_partial_fill_remainder_on_book( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test GTC order partially fills, remainder added to book. + + This test requires a controlled environment to verify partial fill behavior. + When external liquidity exists, taker orders would match against it first, + making it impossible to verify specific partial fill behavior. + + Flow: + 1. Check for external liquidity - skip if present + 2. Maker places small GTC buy order + 3. Taker places larger GTC sell order at crossing price + 4. Verify maker order is fully filled + 5. Verify taker order is partially filled with remainder on book + 6. Cancel taker's remaining order + """ + logger.info("=" * 80) + logger.info(f"SPOT GTC PARTIAL FILL TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists - taker would match against it first + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping partial fill test: external liquidity exists. " + "Taker orders would match against external liquidity first." + ) + + # Maker places small GTC buy order + maker_price = spot_config.price(0.99) + maker_qty = spot_config.min_qty + + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + + logger.info(f"Maker placing GTC buy: {maker_qty} @ ${maker_price:.2f}") + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id}") + + # Taker places larger GTC sell order at crossing price + taker_qty = "0.002" # Larger than maker (0.001) to test partial fill + + taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.99).qty(taker_qty).gtc().build() + + logger.info(f"Taker placing GTC sell: {taker_qty} @ ${maker_price:.2f}") + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker order sent: {taker_order_id}") + + # Wait for matching + await asyncio.sleep(0.1) + + # Verify maker order is fully filled + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Maker order fully filled") + + # Verify taker order is partially filled (remainder on book) + open_orders = await taker_tester.client.get_open_orders() + taker_open = [o for o in open_orders if o.order_id == taker_order_id] + + assert len(taker_open) == 1, f"Taker order {taker_order_id} should still be on book with remainder" + logger.info("✅ Taker order partially filled, remainder on book") + + # Cleanup - cancel taker's remaining order + await taker_tester.client.cancel_order( + order_id=taker_order_id, symbol=spot_config.symbol, account_id=taker_tester.account_id + ) + await asyncio.sleep(0.05) + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT GTC PARTIAL FILL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.gtc +@pytest.mark.asyncio +async def test_spot_gtc_no_match_added_to_book(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test GTC order added to book when no match exists. + + Uses a safe no-match price to ensure the order doesn't match existing liquidity. + + Flow: + 1. Check order book for safe no-match price + 2. Place GTC buy order at that price + 3. Verify order is on book (not filled) + 4. Verify order appears in L2 depth + 5. Cancel order + """ + logger.info("=" * 80) + logger.info(f"SPOT GTC NO MATCH (ADDED TO BOOK) TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Check order book to get safe no-match price + await spot_config.refresh_order_book(spot_tester.data) + + # Get a buy price guaranteed not to match (below all asks) + order_price = spot_config.get_safe_no_match_buy_price() + logger.info(f"Safe no-match buy price: ${order_price:.2f}") + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(order_price)).gtc().build() + + logger.info(f"Placing GTC buy: {spot_config.min_qty} @ ${order_price:.2f}") + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ Order created: {order_id}") + + # Verify order is on book (open orders) + open_orders = await spot_tester.client.get_open_orders() + order_on_book = any(o.order_id == order_id for o in open_orders) + assert order_on_book, f"Order {order_id} should be on book" + logger.info("✅ Order is on book (open orders)") + + # Verify order appears in L2 depth + await asyncio.sleep(0.1) + depth = await spot_tester.data.market_depth(spot_config.symbol) + assert isinstance(depth, Depth), f"Expected Depth type, got {type(depth)}" + bids = depth.bids + + found_in_depth = False + for bid in bids: + price = float(bid.px) + if abs(price - float(order_price)) < 10.0: + found_in_depth = True + logger.info(f"✅ Order found in L2 depth at ${price:.2f}") + break + + assert found_in_depth, f"Order at ${order_price:.2f} not found in L2 depth" + + # Cleanup + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT GTC NO MATCH TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.gtc +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_gtc_price_time_priority_fifo( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test multiple GTC orders at same price filled in FIFO order. + + This test requires a controlled environment to verify FIFO behavior. + When external liquidity exists, taker orders would match against it first, + making it impossible to verify specific FIFO behavior. + + Flow: + 1. Check for external liquidity - skip if present + 2. Maker places first GTC buy order at price X + 3. Maker places second GTC buy order at same price X + 4. Taker places GTC sell order that fills one order + 5. Verify first order is filled (FIFO) + 6. Verify second order remains on book + """ + logger.info("=" * 80) + logger.info(f"SPOT GTC PRICE-TIME PRIORITY (FIFO) TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists - taker would match against it first + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping FIFO test: external liquidity exists. " + "Taker orders would match against external liquidity first." + ) + + # Same price for both maker orders + maker_price = spot_config.price(0.99) + + # First maker order + first_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + + logger.info(f"Maker placing FIRST GTC buy: {spot_config.min_qty} @ ${maker_price:.2f}") + first_order_id = await maker_tester.orders.create_limit(first_params) + await maker_tester.wait.for_order_creation(first_order_id) + logger.info(f"✅ First order created: {first_order_id}") + + # Small delay to ensure time priority + await asyncio.sleep(0.05) + + # Second maker order at same price + second_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + + logger.info(f"Maker placing SECOND GTC buy: {spot_config.min_qty} @ ${maker_price:.2f}") + second_order_id = await maker_tester.orders.create_limit(second_params) + await maker_tester.wait.for_order_creation(second_order_id) + logger.info(f"✅ Second order created: {second_order_id}") + + # Taker places sell order that fills exactly one order + taker_price = maker_price + + taker_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .sell() + .price(str(taker_price)) + .qty(spot_config.min_qty) # Same qty as one maker order + .gtc() + .build() + ) + + logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${taker_price:.2f}") + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker order sent: {taker_order_id}") + + # Wait for matching + await asyncio.sleep(0.05) + + # Verify first order is filled (FIFO) + await maker_tester.wait.for_order_state(first_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ First order filled (FIFO priority)") + + # Verify second order is still on book + open_orders = await maker_tester.client.get_open_orders() + second_still_open = any(o.order_id == second_order_id for o in open_orders) + assert second_still_open, f"Second order {second_order_id} should still be on book" + logger.info("✅ Second order remains on book") + + # Cleanup + await maker_tester.client.cancel_order( + order_id=second_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id + ) + await asyncio.sleep(0.05) + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT GTC FIFO TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.gtc +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_gtc_best_price_first( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test GTC order matches best prices first. + + This test requires a controlled environment to verify best-price-first behavior. + When external liquidity exists, taker orders would match against it first, + making it impossible to verify specific price priority behavior. + + Flow: + 1. Check for external liquidity - skip if present + 2. Maker places GTC buy order at price X + 3. Maker places GTC buy order at better price Y (Y > X) + 4. Taker places GTC sell order + 5. Verify better price order (Y) is filled first + """ + logger.info("=" * 80) + logger.info(f"SPOT GTC BEST PRICE FIRST TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists - taker would match against it first + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping best-price-first test: external liquidity exists. " + "Taker orders would match against external liquidity first." + ) + + # First order at lower price (within oracle deviation) + lower_price = spot_config.price(0.96) + + lower_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() + + logger.info(f"Maker placing GTC buy at LOWER price: {spot_config.min_qty} @ ${lower_price:.2f}") + lower_order_id = await maker_tester.orders.create_limit(lower_params) + await maker_tester.wait.for_order_creation(lower_order_id) + logger.info(f"✅ Lower price order created: {lower_order_id}") + + # Second order at higher (better for seller) price + higher_price = spot_config.price(0.99) + + higher_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + + logger.info(f"Maker placing GTC buy at HIGHER price: {spot_config.min_qty} @ ${higher_price:.2f}") + higher_order_id = await maker_tester.orders.create_limit(higher_params) + await maker_tester.wait.for_order_creation(higher_order_id) + logger.info(f"✅ Higher price order created: {higher_order_id}") + + # Taker places sell order that fills exactly one order + taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.96).gtc().build() + + logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${lower_price:.2f}") + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker order sent: {taker_order_id}") + + # Wait for matching + await asyncio.sleep(0.05) + + # Verify higher price order is filled first (best price for seller) + await maker_tester.wait.for_order_state(higher_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Higher price order filled first (best price priority)") + + # Verify lower price order is still on book + open_orders = await maker_tester.client.get_open_orders() + lower_still_open = any(o.order_id == lower_order_id for o in open_orders) + assert lower_still_open, f"Lower price order {lower_order_id} should still be on book" + logger.info("✅ Lower price order remains on book") + + # Cleanup + await maker_tester.client.cancel_order( + order_id=lower_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id + ) + await asyncio.sleep(0.05) + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT GTC BEST PRICE FIRST TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.gtc +@pytest.mark.asyncio +async def test_spot_gtc_with_client_order_id(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test GTC order with clientOrderId tracked correctly. + + Flow: + 1. Place GTC order with custom clientOrderId + 2. Verify clientOrderId is in response + 3. Verify order can be queried and has correct clientOrderId + 4. Cancel order using client_order_id (not order_id) + """ + logger.info("=" * 80) + logger.info(f"SPOT GTC WITH CLIENT ORDER ID TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Generate unique client order ID (positive integer, fits in uint64) + test_client_order_id = random.randint(1, 2**32 - 1) # nosec B311 + + order_price = spot_config.price(0.96) + + order_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .buy() + .price(str(order_price)) + .qty(spot_config.min_qty) + .gtc() + .client_order_id(test_client_order_id) + .build() + ) + + logger.info(f"Placing GTC buy with clientOrderId={test_client_order_id}...") + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ Order created: {order_id}") + + # Verify the order has the clientOrderId + open_orders = await spot_tester.client.get_open_orders() + our_order = next((o for o in open_orders if o.order_id == order_id), None) + assert our_order is not None, f"Order {order_id} not found in open orders" + + # Check if clientOrderId is returned in the response + # Note: clientOrderId may be in additional_properties or as a direct attribute + if hasattr(our_order, "client_order_id") and our_order.client_order_id is not None: + logger.info(f"✅ clientOrderId verified: {our_order.client_order_id}") + assert ( + our_order.client_order_id == test_client_order_id + ), f"Expected clientOrderId {test_client_order_id}, got {our_order.client_order_id}" + else: + logger.info("Note: clientOrderId not returned in get_open_orders response") + + # Cancel using both order_id and client_order_id + # The API should prefer order_id when both are provided + logger.info(f"Cancelling order using both order_id={order_id} and clientOrderId={test_client_order_id}...") + await spot_tester.client.cancel_order( + order_id=order_id, + client_order_id=test_client_order_id, + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + logger.info("✅ Order cancelled (API prefers order_id when both provided)") + + logger.info("✅ SPOT GTC WITH CLIENT ORDER ID TEST COMPLETED") diff --git a/tests/test_spot/test_ioc_orders.py b/tests/test_spot/test_ioc_orders.py new file mode 100644 index 00000000..ce02e54c --- /dev/null +++ b/tests/test_spot/test_ioc_orders.py @@ -0,0 +1,519 @@ +""" +Tests for spot IOC (Immediate-Or-Cancel) orders. + +IOC orders execute immediately against available liquidity and cancel +any unfilled portion. These tests verify IOC behavior for spot markets. + +These tests support both empty and non-empty order books: +- When external liquidity exists, tests use it instead of providing their own +- When no external liquidity exists, tests provide maker liquidity as before +- Execution assertions are flexible to handle order book changes between submission and fill +""" + +from typing import Optional + +import asyncio +import time +from decimal import Decimal + +import pytest +from eth_abi.exceptions import EncodingError + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.reya_tester import limit_order_params_to_order, logger +from tests.test_spot.spot_config import SpotTestConfig + + +@pytest.mark.spot +@pytest.mark.ioc +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_ioc_full_fill(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): + """ + Test IOC order that fully fills against existing liquidity. + + Supports both empty and non-empty order books: + - If external bid liquidity exists, taker sells into it + - If no external liquidity, maker provides bid liquidity first + + Flow: + 1. Check for external bid liquidity + 2. If needed, maker places GTC buy order on the book + 3. Taker sends IOC sell order that matches + 4. Verify execution occurred + """ + logger.info("=" * 80) + logger.info(f"SPOT IOC FULL FILL TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Clear any existing orders from our accounts + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + # Record taker's initial balances for verification + taker_balances_before = await taker_tester.data.balances() + eth_balance_before = taker_balances_before.get("ETH") + rusd_balance_before = taker_balances_before.get("RUSD") + taker_eth_before = Decimal(str(eth_balance_before.real_balance)) if eth_balance_before is not None else Decimal("0") + taker_rusd_before = ( + Decimal(str(rusd_balance_before.real_balance)) if rusd_balance_before is not None else Decimal("0") + ) + logger.info(f"Taker initial balances: ETH={taker_eth_before}, RUSD={taker_rusd_before}") + + maker_order_id: Optional[str] = None + fill_price: Decimal + + # Step 1: Determine liquidity source - check both bid and ask + usable_bid_price = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid_price is not None: + # External bid liquidity exists - taker sells into it + fill_price = usable_bid_price + logger.info(f"Using external bid liquidity at ${fill_price:.2f}") + taker_order_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() + taker_order_id = await taker_tester.orders.create_limit(taker_order_params) + logger.info(f"Taker IOC sell order sent: {taker_order_id} @ ${fill_price:.2f}") + elif usable_ask_price is not None: + # External ask liquidity exists - taker buys from it + fill_price = usable_ask_price + logger.info(f"Using external ask liquidity at ${fill_price:.2f}") + taker_order_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).ioc().build() + taker_order_id = await taker_tester.orders.create_limit(taker_order_params) + logger.info(f"Taker IOC buy order sent: {taker_order_id} @ ${fill_price:.2f}") + else: + # No external liquidity - provide our own + maker_price = spot_config.price(0.99) + fill_price = Decimal(str(maker_price)) + + maker_order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + maker_order_id = await maker_tester.orders.create_limit(maker_order_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker GTC buy order created: {maker_order_id} @ ${fill_price:.2f}") + + taker_order_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() + taker_order_id = await taker_tester.orders.create_limit(taker_order_params) + logger.info(f"Taker IOC sell order sent: {taker_order_id} @ ${fill_price:.2f}") + + # Step 3: Wait for execution + expected_taker_order = limit_order_params_to_order(taker_order_params, taker_tester.account_id) + execution = await taker_tester.wait.for_spot_execution(taker_order_id, expected_taker_order) + + # Step 4: Verify execution (flexible - price may differ due to order book changes) + assert execution is not None, "Execution should have occurred" + assert execution.symbol == spot_config.symbol, "Symbol should match" + assert Decimal(execution.qty) <= Decimal(spot_config.min_qty), "Qty should not exceed order qty" + + # Verify fill price is within circuit breaker range + exec_price = Decimal(execution.price) + assert spot_config.circuit_breaker_floor <= exec_price <= spot_config.circuit_breaker_ceiling, ( + f"Fill price ${exec_price} should be within circuit breaker range " + f"[${spot_config.circuit_breaker_floor}, ${spot_config.circuit_breaker_ceiling}]" + ) + logger.info(f"✅ Execution verified: order_id={execution.order_id}, price=${exec_price:.2f}") + + # Verify maker order is filled (if we placed one) + if maker_order_id: + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Maker order filled") + + # Verify no open orders remain from our accounts + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + # Step 5: Verify taker's balance changed correctly + # Wait for balances to update + await asyncio.sleep(0.5) + taker_balances_after = await taker_tester.data.balances() + eth_balance_after = taker_balances_after.get("ETH") + rusd_balance_after = taker_balances_after.get("RUSD") + taker_eth_after = Decimal(str(eth_balance_after.real_balance)) if eth_balance_after is not None else Decimal("0") + taker_rusd_after = Decimal(str(rusd_balance_after.real_balance)) if rusd_balance_after is not None else Decimal("0") + logger.info(f"Taker final balances: ETH={taker_eth_after}, RUSD={taker_rusd_after}") + + # Taker sold ETH, so ETH should decrease and RUSD should increase + taker_eth_change = taker_eth_after - taker_eth_before + taker_rusd_change = taker_rusd_after - taker_rusd_before + logger.info(f"Taker balance changes: ETH={taker_eth_change}, RUSD={taker_rusd_change}") + + # Verify ETH decreased (taker sold ETH) + assert taker_eth_change < Decimal("0"), f"Taker ETH should decrease after selling, got change: {taker_eth_change}" + # Verify RUSD increased (taker received RUSD) + assert taker_rusd_change > Decimal( + "0" + ), f"Taker RUSD should increase after selling, got change: {taker_rusd_change}" + logger.info("✅ Taker balance changes verified (ETH decreased, RUSD increased)") + + logger.info("✅ SPOT IOC FULL FILL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.ioc +@pytest.mark.asyncio +async def test_spot_ioc_no_match_cancels(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test IOC order that finds no matching liquidity and cancels. + + Supports both empty and non-empty order books: + - Uses a price guaranteed not to match any existing liquidity + - Price is calculated to be below all asks (for buy) or above all bids (for sell) + + Flow: + 1. Check current order book state + 2. Calculate a safe no-match price + 3. Send IOC order at that price + 4. Verify order is cancelled/rejected (not filled) + + Note: IOC orders without matching liquidity may return a 400 error + or return None for order_id, depending on the API implementation. + """ + logger.info("=" * 80) + logger.info(f"SPOT IOC NO MATCH TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Clear any existing orders from our account + await spot_tester.check.no_open_orders() + + # Check current order book to determine safe no-match price + await spot_config.refresh_order_book(spot_tester.data) + + # Clear execution tracking + spot_tester.ws.last_spot_execution = None + start_timestamp = int(time.time() * 1000) + + # Get a buy price guaranteed not to match (below all asks) + safe_buy_price = spot_config.get_safe_no_match_buy_price() + logger.info(f"Safe no-match buy price: ${safe_buy_price:.2f}") + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(safe_buy_price)).ioc().build() + + logger.info(f"Sending IOC buy at ${safe_buy_price:.2f} (expecting no match)...") + + # IOC orders without matching liquidity may raise an error or return None + try: + order_id = await spot_tester.orders.create_limit(order_params) + logger.info(f"IOC order response: {order_id}") + + # If we get here, wait and verify no execution + await asyncio.sleep(0.1) + + last_exec = spot_tester.ws.spot_executions.last + if last_exec is not None and last_exec.timestamp > start_timestamp: + pytest.fail("IOC order should not have executed") + + logger.info("✅ IOC order returned but no execution occurred") + + except ApiException as e: + # IOC orders without liquidity may be rejected with an error + logger.info(f"✅ IOC order rejected as expected: {type(e).__name__}") + + # Verify no open orders (IOC should be cancelled/rejected) + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT IOC NO MATCH TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.ioc +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_ioc_partial_fill(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): + """ + Test IOC order that matches against available liquidity. + + When taker sends a larger IOC order than available quantity, + the IOC fills what it can and the remainder is cancelled. + + Supports both empty and non-empty order books: + - Checks existing bid liquidity and supplements if needed + - Taker sends IOC sell larger than available to test partial fill behavior + + Flow: + 1. Check external bid liquidity + 2. Supplement with maker order if needed to ensure known qty + 3. Taker sends larger IOC order that partially fills + 4. Verify execution occurred + 5. Verify no open orders remain + """ + logger.info("=" * 80) + logger.info(f"SPOT IOC PARTIAL FILL TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Clear any existing orders for both accounts + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + maker_order_id: Optional[str] = None + maker_qty = spot_config.min_qty + taker_qty = "0.002" # Larger than maker qty - will partially fill + + # Determine fill price and ensure we have known liquidity - check both bid and ask + usable_bid_price = spot_config.get_usable_bid_price_for_qty(maker_qty) + usable_ask_price = spot_config.get_usable_ask_price_for_qty(maker_qty) + + if usable_bid_price is not None: + # External bid liquidity exists - taker sells into it + fill_price = usable_bid_price + logger.info(f"Using external bid liquidity at ${fill_price:.2f}") + taker_order_params = ( + OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).qty(taker_qty).ioc().build() + ) + logger.info(f"Taker sending IOC sell: {taker_qty} @ ${fill_price:.2f}") + elif usable_ask_price is not None: + # External ask liquidity exists - taker buys from it + fill_price = usable_ask_price + logger.info(f"Using external ask liquidity at ${fill_price:.2f}") + taker_order_params = ( + OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).qty(taker_qty).ioc().build() + ) + logger.info(f"Taker sending IOC buy: {taker_qty} @ ${fill_price:.2f}") + else: + # No external liquidity - provide our own + maker_price = spot_config.price(0.99) + fill_price = Decimal(str(maker_price)) + + maker_order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + maker_order_id = await maker_tester.orders.create_limit(maker_order_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id} @ ${fill_price:.2f}") + + taker_order_params = ( + OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).qty(taker_qty).ioc().build() + ) + logger.info(f"Taker sending IOC sell: {taker_qty} @ ${fill_price:.2f}") + taker_tester.ws.last_spot_execution = None + taker_order_id = await taker_tester.orders.create_limit(taker_order_params) + logger.info(f"Taker IOC order sent: {taker_order_id}") + + # Wait for execution + await asyncio.sleep(0.1) + + # Verify maker order is filled (if we placed one) + if maker_order_id: + try: + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Maker order fully filled - execution confirmed") + except (TimeoutError, RuntimeError): + open_orders = await maker_tester.client.get_open_orders() + maker_still_open = any(o.order_id == maker_order_id for o in open_orders) + if maker_still_open: + raise AssertionError(f"Maker order {maker_order_id} should have been filled but is still open") + logger.info("✅ Maker order no longer open - execution confirmed") + + # Verify no open orders remain from our accounts (IOC remainder was cancelled) + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT IOC PARTIAL FILL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.ioc +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_ioc_sell_full_fill(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): + """ + Test IOC buy order fully filled against existing sell liquidity. + + Supports both empty and non-empty order books: + - If external ask liquidity exists, taker buys into it + - If no external liquidity, maker provides ask liquidity first + + Flow: + 1. Check for external ask liquidity + 2. If needed, maker places GTC sell order on the book + 3. Taker sends IOC buy order that matches + 4. Verify execution occurred + """ + logger.info("=" * 80) + logger.info(f"SPOT IOC SELL FULL FILL TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + maker_order_id: Optional[str] = None + fill_price: Decimal + + # Determine liquidity source + usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_ask_price is not None: + # External ask liquidity exists - use it + fill_price = usable_ask_price + logger.info(f"Using external ask liquidity at ${fill_price:.2f}") + else: + # No external liquidity - provide our own + maker_price = spot_config.price(1.01) + fill_price = Decimal(str(maker_price)) + + maker_order_params = OrderBuilder.from_config(spot_config).sell().at_price(1.01).gtc().build() + maker_order_id = await maker_tester.orders.create_limit(maker_order_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker GTC sell order created: {maker_order_id} @ ${fill_price:.2f}") + + # Taker sends IOC buy order + taker_order_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).ioc().build() + + logger.info(f"Taker sending IOC buy: {spot_config.min_qty} @ ${fill_price:.2f}") + taker_order_id = await taker_tester.orders.create_limit(taker_order_params) + logger.info(f"Taker IOC order sent: {taker_order_id}") + + # Wait for matching + await asyncio.sleep(0.1) + + # Verify maker order is filled (if we placed one) + if maker_order_id: + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Maker order filled") + + # Verify no open orders remain from our accounts + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT IOC SELL FULL FILL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.ioc +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_ioc_multiple_price_level_crossing( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test IOC order that crosses multiple price levels. + + This test requires a controlled environment to verify multi-level matching behavior. + When external liquidity exists, we skip to avoid unpredictable matching. + + Flow: + 1. Check for external liquidity - skip if present + 2. Maker places multiple GTC orders at different prices + 3. Taker sends large IOC order that fills across multiple levels + 4. Verify all maker orders are filled + """ + logger.info("=" * 80) + logger.info(f"SPOT IOC MULTIPLE PRICE LEVEL TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists - this test needs controlled environment + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping multi-level crossing test: external liquidity exists. " + "This test requires a controlled environment to verify multi-level matching." + ) + + # Maker places multiple GTC buy orders at different prices within oracle deviation + price_1 = spot_config.price(0.97) # Lower price + price_2 = spot_config.price(0.99) # Higher price (better for seller) + qty_per_order = spot_config.min_qty + + # First order at lower price + order_1_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + + logger.info(f"Maker placing GTC buy #1: {qty_per_order} @ ${price_1:.2f}") + order_1_id = await maker_tester.orders.create_limit(order_1_params) + await maker_tester.wait.for_order_creation(order_1_id) + logger.info(f"✅ Order #1 created: {order_1_id}") + + # Second order at higher price + order_2_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + + logger.info(f"Maker placing GTC buy #2: {qty_per_order} @ ${price_2:.2f}") + order_2_id = await maker_tester.orders.create_limit(order_2_params) + await maker_tester.wait.for_order_creation(order_2_id) + logger.info(f"✅ Order #2 created: {order_2_id}") + + # Taker sends IOC sell order large enough to fill both our orders + taker_price = price_1 # Same as lower price ensures within oracle deviation + taker_qty = "0.002" # Enough to fill both orders (2 x 0.001) + + taker_order_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).qty(taker_qty).ioc().build() + + logger.info(f"Taker sending IOC sell: {taker_qty} @ ${taker_price:.2f}") + taker_order_id = await taker_tester.orders.create_limit(taker_order_params) + logger.info(f"Taker IOC order sent: {taker_order_id}") + + # Wait for matching + await asyncio.sleep(0.1) + + # Verify both maker orders are filled + await maker_tester.wait.for_order_state(order_1_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Order #1 filled") + + await maker_tester.wait.for_order_state(order_2_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Order #2 filled") + + # Verify no open orders remain from our accounts + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT IOC MULTIPLE PRICE LEVEL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.ioc +@pytest.mark.asyncio +async def test_spot_ioc_price_qty_validation(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test IOC order rejected for invalid price/qty. + + Flow: + 1. Send IOC order with zero quantity + 2. Verify order is rejected with validation error + 3. Send IOC order with negative price + 4. Verify order is rejected with validation error + """ + logger.info("=" * 80) + logger.info(f"SPOT IOC PRICE/QTY VALIDATION TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Test 1: Zero quantity + zero_qty_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).qty("0").ioc().build() + + logger.info("Sending IOC order with zero quantity...") + try: + order_id = await spot_tester.orders.create_limit(zero_qty_params) + # If we get here without error, the API might accept it but not execute + logger.info(f"Order accepted (may be rejected later): {order_id}") + except ApiException as e: + logger.info(f"✅ Zero quantity order rejected: {type(e).__name__}") + + # Test 2: Negative price (if supported by builder) + try: + negative_price_params = OrderBuilder.from_config(spot_config).buy().price("-100").ioc().build() + + logger.info("Sending IOC order with negative price...") + order_id = await spot_tester.orders.create_limit(negative_price_params) + logger.info(f"Order accepted (may be rejected later): {order_id}") + except ApiException as e: + logger.info(f"✅ Negative price order rejected: {type(e).__name__}") + except EncodingError as e: + # eth_abi raises ValueOutOfBounds (subclass of EncodingError) for negative prices + logger.info(f"✅ Negative price order rejected: {type(e).__name__}") + + # Verify no open orders + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT IOC PRICE/QTY VALIDATION TEST COMPLETED") diff --git a/tests/test_spot/test_maker_taker_matching.py b/tests/test_spot/test_maker_taker_matching.py new file mode 100644 index 00000000..4da9b991 --- /dev/null +++ b/tests/test_spot/test_maker_taker_matching.py @@ -0,0 +1,220 @@ +""" +End-to-end test for spot maker-taker matching. + +This test uses TWO separate accounts to verify the complete spot trading flow: +- Maker account: Places GTC limit order on the book +- Taker account: Sends IOC order that matches against maker + +Supports both empty and non-empty order books: +- When external liquidity exists, tests can use it +- When no external liquidity exists, tests provide maker liquidity as before +""" + +import asyncio + +import pytest + +from sdk.open_api.models.depth import Depth +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.reya_tester import limit_order_params_to_order, logger +from tests.test_spot.spot_config import SpotTestConfig + + +@pytest.mark.spot +@pytest.mark.maker_taker +@pytest.mark.e2e +@pytest.mark.asyncio +async def test_spot_maker_taker_matching( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + End-to-end test for spot trading using TWO separate accounts. + + This test requires a controlled environment to verify balance changes + between our maker and taker accounts. When external liquidity exists, + we skip to avoid unpredictable balance changes. + + This tests: + 1. Maker places GTC limit order + 2. Taker sends IOC order that matches + 3. Verify order matching occurs + 4. Check all relevant endpoints: + - spotExecutions (REST + WS) + - balances (REST + WS) + - orderChanges (WS) + - L2 depth + """ + logger.info("=" * 80) + logger.info(f"SPOT TRADING E2E TEST: {spot_config.symbol}") + logger.info("=" * 80) + logger.info(f"🏭 Maker Account: {maker_tester.account_id}") + logger.info(f"🎯 Taker Account: {taker_tester.account_id}") + logger.info(f"Using oracle price for orders: ${spot_config.oracle_price:.2f}") + + # Clear any existing orders for BOTH accounts + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + # Check current order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists - this test needs controlled environment for balance verification + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping E2E maker-taker test: external liquidity exists. " + "This test requires a controlled environment to verify balance changes." + ) + + # Get initial balances for both accounts + logger.info("\n📊 Getting initial balances...") + maker_initial_balances = await maker_tester.data.balances() + taker_initial_balances = await taker_tester.data.balances() + logger.info(f"Maker initial balances: {[(b.asset, b.real_balance) for b in maker_initial_balances.values()]}") + logger.info(f"Taker initial balances: {[(b.asset, b.real_balance) for b in taker_initial_balances.values()]}") + + # Step 1: Place maker order (GTC buy within oracle deviation) + logger.info("\n📋 Step 1: Placing maker order (GTC buy)...") + maker_price = spot_config.price(0.99) + + maker_order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + + maker_order_id = await maker_tester.orders.create_limit(maker_order_params) + logger.info(f"Created maker order with ID: {maker_order_id} at price ${maker_price:.2f}") + + # Wait for maker order creation confirmation + await maker_tester.wait.for_order_creation(maker_order_id) + expected_maker_order = limit_order_params_to_order(maker_order_params, maker_tester.account_id) + await maker_tester.check.open_order_created(maker_order_id, expected_maker_order) + logger.info("✅ Maker order confirmed on the book") + + # Step 2: Check L2 depth to verify maker order is visible + logger.info("\n📊 Step 2: Checking L2 depth...") + await asyncio.sleep(0.05) + + depth = await maker_tester.data.market_depth(spot_config.symbol) + assert isinstance(depth, Depth), f"Expected Depth type, got {type(depth)}" + logger.info(f"Market depth type: {depth.type}") + logger.info(f"Bids: {len(depth.bids)} levels") + logger.info(f"Asks: {len(depth.asks)} levels") + + # Verify our maker order appears in the bids (using typed Level.px/qty attributes) + bids = depth.bids + maker_order_found = False + for bid in bids: + bid_price = float(bid.px) + bid_qty = float(bid.qty) + logger.info(f" Bid: ${bid_price:.2f} x {bid_qty:.6f}") + if abs(bid_price - maker_price) < 0.01: + maker_order_found = True + logger.info(f" ✅ Found our maker order in L2 depth at ${bid_price:.2f}") + + if not maker_order_found: + logger.warning(f"⚠️ Maker order at ${maker_price:.2f} not found in L2 depth - may have been matched already") + else: + logger.info("✅ Maker order visible in L2 depth") + + # Step 3: Place taker order (IOC sell at maker price to guarantee match) + logger.info("\n📋 Step 3: Placing taker order (IOC sell to match maker)...") + taker_price = maker_price # Same price ensures within oracle deviation + + # Clear balance updates before placing order + maker_tester.ws.clear_balance_updates() + taker_tester.ws.clear_balance_updates() + + taker_order_params = OrderBuilder.from_config(spot_config).sell().at_price(0.99).ioc().build() + + taker_order_id = await taker_tester.orders.create_limit(taker_order_params) + logger.info(f"Created taker order with ID: {taker_order_id} at price ${taker_price:.2f}") + + # Step 4: Wait for spot execution confirmation + logger.info("\n⏳ Step 4: Waiting for spot execution...") + expected_taker_order = limit_order_params_to_order(taker_order_params, taker_tester.account_id) + + # Strict matching on order_id and all fields + taker_execution = await taker_tester.wait.for_spot_execution(taker_order_id, expected_taker_order) + logger.info(f"✅ Taker execution confirmed: {taker_execution.order_id}") + + await taker_tester.check.spot_execution(taker_execution, expected_taker_order) + logger.info("✅ Taker execution details validated") + + # Step 5: Verify maker order was filled + logger.info("\n📋 Step 5: Checking maker order status...") + try: + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Maker order fully filled") + except RuntimeError: + logger.info("⚠️ Maker order might be partially filled or still open") + + # Step 6: Verify balances changed appropriately + logger.info("\n💰 Step 6: Verifying balance changes...") + await asyncio.sleep(0.1) + + maker_final_balances = await maker_tester.data.balances() + taker_final_balances = await taker_tester.data.balances() + + # For WETHRUSD: base=ETH, quote=RUSD + base_asset = spot_config.symbol.replace("W", "").replace("RUSD", "") # "WETHRUSD" -> "ETH" + quote_asset = "RUSD" + + maker_tester.ws.verify_spot_trade_balance_changes( + maker_initial_balances=maker_initial_balances, + maker_final_balances=maker_final_balances, + taker_initial_balances=taker_initial_balances, + taker_final_balances=taker_final_balances, + base_asset=base_asset, + quote_asset=quote_asset, + qty=spot_config.min_qty, + price=str(maker_price), + is_maker_buyer=True, + ) + + # Verify balance updates via WebSocket + # Each tester has its own WebSocket connection subscribed to its own wallet + logger.info("\n💰 Verifying balance updates via WebSocket...") + maker_balance_updates = [b for b in maker_tester.ws.balance_updates if b.account_id == maker_tester.account_id] + taker_balance_updates = [b for b in taker_tester.ws.balance_updates if b.account_id == taker_tester.account_id] + + logger.info(f"Maker received {len(maker_balance_updates)} balance updates via WS") + logger.info(f"Taker received {len(taker_balance_updates)} balance updates via WS") + + assert ( + len(maker_balance_updates) == 2 + ), f"Maker should receive exactly 2 balance updates (ETH + RUSD), got {len(maker_balance_updates)}" + assert ( + len(taker_balance_updates) == 2 + ), f"Taker should receive exactly 2 balance updates (ETH + RUSD), got {len(taker_balance_updates)}" + + maker_assets = {b.asset for b in maker_balance_updates} + taker_assets = {b.asset for b in taker_balance_updates} + logger.info(f"✅ Maker balance updates received for: {maker_assets}") + logger.info(f"✅ Taker balance updates received for: {taker_assets}") + + assert maker_assets == {"ETH", "RUSD"}, f"Maker should have both ETH and RUSD updates, got {maker_assets}" + assert taker_assets == {"ETH", "RUSD"}, f"Taker should have both ETH and RUSD updates, got {taker_assets}" + + logger.info("✅ Balance updates verified via WebSocket for both accounts") + + # Step 7: Verify order changes via WebSocket + logger.info("\n📨 Step 7: Verifying order changes via WebSocket...") + assert maker_order_id is not None, "Maker order_id should not be None" + assert maker_order_id in maker_tester.ws.order_changes, "Maker order should be in WS order changes" + if taker_order_id is not None and taker_order_id in taker_tester.ws.order_changes: + logger.info("✅ Taker order changes received via WebSocket") + logger.info("✅ Order changes verification completed") + + # Step 8: Verify spot execution via WebSocket + logger.info("\n📊 Step 8: Verifying spot execution via WebSocket...") + assert taker_tester.ws.last_spot_execution is not None, "Should have received spot execution via WS" + assert taker_tester.ws.last_spot_execution.symbol == spot_config.symbol + logger.info("✅ Spot execution received via WebSocket") + + # Cleanup + logger.info("\n🧹 Cleanup: Cancelling any remaining orders...") + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + logger.info("\n%s", "=" * 80) + logger.info("✅ SPOT TRADING E2E TEST COMPLETED SUCCESSFULLY") + logger.info("=" * 80) diff --git a/tests/test_spot/test_market_data.py b/tests/test_spot/test_market_data.py new file mode 100644 index 00000000..c687f9a2 --- /dev/null +++ b/tests/test_spot/test_market_data.py @@ -0,0 +1,317 @@ +""" +Spot Market Data Tests + +Tests for spot-specific market data: +- Market definitions +- Spot executions via REST +- Spot executions pagination +""" + +import asyncio +import logging +from decimal import Decimal + +import pytest + +from sdk.open_api.models import OrderStatus +from sdk.open_api.models.depth import Depth +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import OrderBuilder +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + + +@pytest.mark.spot +@pytest.mark.market_data +@pytest.mark.asyncio +async def test_spot_market_definitions(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test getting spot market definitions. + + Flow: + 1. Get spot market definitions via REST API + 2. Verify spot markets are included + 3. Verify correct structure + """ + logger.info("=" * 80) + logger.info("SPOT MARKET DEFINITIONS TEST") + logger.info("=" * 80) + + # Get spot market definitions via REST API + spot_markets = await spot_tester.client.reference.get_spot_market_definitions() + + logger.info(f"Spot markets found: {len(spot_markets)}") + + # Verify WETHRUSD exists + weth_market = None + for m in spot_markets: + if hasattr(m, "symbol") and m.symbol == spot_config.symbol: + weth_market = m + break + + assert weth_market is not None, f"Spot market {spot_config.symbol} not found in definitions" + logger.info(f"✅ Found {spot_config.symbol} market") + + # Log some market details + if hasattr(weth_market, "market_id"): + logger.info(f" Market ID: {weth_market.market_id}") + if hasattr(weth_market, "base_asset"): + logger.info(f" Base Asset: {weth_market.base_asset}") + if hasattr(weth_market, "quote_asset"): + logger.info(f" Quote Asset: {weth_market.quote_asset}") + + logger.info("✅ SPOT MARKET DEFINITIONS TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.market_data +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_executions_rest(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): + """ + Test getting spot executions via REST API. + + Supports both empty and non-empty order books: + - If external bid liquidity exists, taker sells into it + - If no external liquidity, maker provides bid liquidity first + + Flow: + 1. Check for external liquidity + 2. If needed, maker places GTC buy order + 3. Taker places IOC sell order + 4. Query spot executions via REST + 5. Verify execution data is correct + """ + logger.info("=" * 80) + logger.info("SPOT EXECUTIONS REST TEST") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + maker_order_id = None + usable_bid_price = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid_price is not None: + # External bid liquidity exists - taker sells into it + fill_price = usable_bid_price + logger.info(f"Using external bid liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() + logger.info(f"Taker placing IOC sell at ${fill_price:.2f}...") + elif usable_ask_price is not None: + # External ask liquidity exists - taker buys from it + fill_price = usable_ask_price + logger.info(f"Using external ask liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).ioc().build() + logger.info(f"Taker placing IOC buy at ${fill_price:.2f}...") + else: + # No external liquidity - provide our own + fill_price = Decimal(str(spot_config.price(0.97))) + + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + + logger.info(f"Maker placing GTC buy: {spot_config.min_qty} @ ${fill_price:.2f}") + 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().price(str(fill_price)).ioc().build() + logger.info(f"Taker placing IOC sell at ${fill_price:.2f}...") + await taker_tester.orders.create_limit(taker_params) + + # Wait for execution + await asyncio.sleep(0.05) + if maker_order_id: + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Trade executed") + + # Query spot executions via REST + executions = await taker_tester.client.get_spot_executions() + + logger.info(f"Spot executions returned: {len(executions.data) if hasattr(executions, 'data') else 'N/A'}") + + # Verify we have executions + if hasattr(executions, "data") and len(executions.data) > 0: + latest = executions.data[0] + logger.info("✅ Latest execution:") + if hasattr(latest, "symbol"): + logger.info(f" Symbol: {latest.symbol}") + if hasattr(latest, "qty"): + logger.info(f" Qty: {latest.qty}") + if hasattr(latest, "price"): + logger.info(f" Price: {latest.price}") + else: + logger.info("ℹ️ No executions returned (may need time to propagate)") + + # Cleanup + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT EXECUTIONS REST TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.market_data +@pytest.mark.asyncio +async def test_spot_depth_price_ordering(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test L2 depth shows correct price ordering. + + Bids should be in descending order (highest first). + Asks should be in ascending order (lowest first). + + Flow: + 1. Place multiple orders at different prices + 2. Get L2 depth + 3. Verify price ordering + """ + logger.info("=" * 80) + logger.info("SPOT DEPTH PRICE ORDERING TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Place multiple buy orders at different prices + prices = [ + spot_config.price(0.96), + spot_config.price(0.96), + spot_config.price(0.96), + ] + + order_ids = [] + for price in prices: + order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() + + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + order_ids.append(order_id) + logger.info(f"✅ Order created at ${price:.2f}") + + # Wait for depth to update + await asyncio.sleep(0.1) + + # Get depth + depth = await spot_tester.data.market_depth(spot_config.symbol) + assert isinstance(depth, Depth), f"Expected Depth type, got {type(depth)}" + bids = depth.bids + + logger.info(f"Bids in depth: {len(bids)}") + + # Verify bids are in descending order (highest price first) + if len(bids) >= 2: + bid_prices = [float(b.px) for b in bids] + is_descending = all(bid_prices[i] >= bid_prices[i + 1] for i in range(len(bid_prices) - 1)) + + logger.info(f"Bid prices: {bid_prices[:5]}") # Show first 5 + assert is_descending, f"Bids should be in descending order: {bid_prices}" + logger.info("✅ Bids are in correct descending order") + else: + logger.info("ℹ️ Not enough bids to verify ordering") + + # Cleanup + for order_id in order_ids: + try: + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + except (OSError, RuntimeError): # nosec B110 + pass # Order may have been filled + + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT DEPTH PRICE ORDERING TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.market_data +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_executions_multiple_trades( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test spot executions are correctly recorded for multiple trades. + + This test requires a controlled environment to verify multiple trades at specific prices. + When external liquidity exists, we skip to avoid unpredictable matching. + + Flow: + 1. Check for external liquidity - skip if present + 2. Execute multiple trades + 3. Query executions via REST + 4. Verify executions are recorded + """ + logger.info("=" * 80) + logger.info("SPOT EXECUTIONS MULTIPLE TRADES TEST") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists - this test needs controlled environment + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping multiple trades test: external liquidity exists. " + "This test requires a controlled environment to verify multiple trades." + ) + + # Execute multiple trades + num_trades = 3 + executed_order_ids = [] + + for i in range(num_trades): + # Use prices within 5% of oracle (0.96, 0.97, 0.98) + maker_price = round(spot_config.oracle_price * (0.96 + i * 0.01), 2) + + maker_params = OrderBuilder.from_config(spot_config).buy().price(str(maker_price)).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().price(str(maker_price)).ioc().build() + + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.1) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + executed_order_ids.append(maker_order_id) + logger.info(f"✅ Trade {i + 1}/{num_trades} executed") + + # Wait for executions to be indexed + await asyncio.sleep(0.5) + + # Query executions + executions = await taker_tester.client.get_spot_executions() + + assert hasattr(executions, "data"), "Response should have 'data' attribute" + execution_data = executions.data + + logger.info(f"Total executions returned: {len(execution_data)}") + + # Verify we have executions + assert len(execution_data) > 0, "Should have at least one execution" + logger.info("✅ Executions returned from REST API") + + # Verify execution data structure + latest = execution_data[0] + logger.info("Latest execution:") + if hasattr(latest, "symbol"): + logger.info(f" Symbol: {latest.symbol}") + assert latest.symbol == spot_config.symbol, f"Expected {spot_config.symbol}, got {latest.symbol}" + if hasattr(latest, "qty"): + logger.info(f" Qty: {latest.qty}") + if hasattr(latest, "price"): + logger.info(f" Price: {latest.price}") + if hasattr(latest, "side"): + logger.info(f" Side: {latest.side}") + + logger.info("✅ Execution data structure is correct") + + logger.info("✅ SPOT EXECUTIONS MULTIPLE TRADES TEST COMPLETED") diff --git a/tests/test_spot/test_market_spot_executions.py b/tests/test_spot/test_market_spot_executions.py new file mode 100644 index 00000000..671c25e1 --- /dev/null +++ b/tests/test_spot/test_market_spot_executions.py @@ -0,0 +1,476 @@ +""" +Market Spot Executions Tests + +Tests for the new market-level spot executions endpoints: +- REST API: GET /v2/market/{symbol}/spotExecutions +- WebSocket: /v2/market/{symbol}/spotExecutions channel + +These tests verify: +1. REST API returns correct spot execution data for a market +2. WebSocket channel delivers real-time spot execution updates +3. WebSocket snapshot contains historical executions +4. Data consistency between REST and WebSocket + +These tests always provide their own maker liquidity to ensure predictable +execution behavior for verification purposes. +""" + +import asyncio +import logging +from decimal import Decimal + +import pytest + +from sdk.open_api.exceptions import BadRequestException +from sdk.open_api.models import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + +# REST API TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_rest_get_market_spot_executions_empty(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test REST API returns empty list for market with no recent executions. + + This test verifies the basic endpoint functionality. + """ + logger.info("=" * 80) + logger.info(f"REST GET MARKET SPOT EXECUTIONS (EMPTY) TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Query market spot executions + executions = await spot_tester.client.markets.get_market_spot_executions(symbol=spot_config.symbol) + + # Should return a valid response (may be empty or have historical data) + assert executions is not None, "Should receive a response" + assert hasattr(executions, "data"), "Response should have 'data' attribute" + + logger.info(f"✅ Market spot executions returned: {len(executions.data)} execution(s)") + + # Log execution details if any exist + for exec_item in executions.data[:5]: # Log first 5 + logger.info(f" - {exec_item.symbol}: qty={exec_item.qty}, price={exec_item.price}") + + logger.info("✅ REST GET MARKET SPOT EXECUTIONS (EMPTY) TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_rest_get_market_spot_executions_after_trade( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test REST API returns spot execution after a trade is executed. + + Supports both empty and non-empty order books: + - If external bid liquidity exists, taker sells into it + - If no external liquidity, maker provides bid liquidity first + + Flow: + 1. Check for external liquidity + 2. If needed, maker places GTC buy order + 3. Taker places IOC sell order + 4. Query market spot executions via REST + 5. Verify the execution appears in the response + """ + logger.info("=" * 80) + logger.info(f"REST GET MARKET SPOT EXECUTIONS AFTER TRADE TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + maker_order_id = None + usable_bid_price = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid_price is not None: + # External bid liquidity exists - taker sells into it + fill_price = usable_bid_price + logger.info(f"Using external bid liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() + elif usable_ask_price is not None: + # External ask liquidity exists - taker buys from it + fill_price = usable_ask_price + logger.info(f"Using external ask liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).ioc().build() + else: + # No external liquidity - provide our own + fill_price = Decimal(str(spot_config.price(0.97))) + + 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) + logger.info(f"✅ Maker order created: {maker_order_id} @ ${fill_price:.2f}") + + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() + + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"✅ Taker IOC order sent: {taker_order_id}") + + # Wait for execution + await asyncio.sleep(0.3) + if maker_order_id: + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Trade executed") + + # Step 2: Query market spot executions via REST + executions = await maker_tester.client.markets.get_market_spot_executions(symbol=spot_config.symbol) + + assert executions is not None, "Should receive a response" + assert hasattr(executions, "data"), "Response should have 'data' attribute" + assert len(executions.data) > 0, "Should have at least one execution" + + logger.info(f"✅ Market spot executions returned: {len(executions.data)} execution(s)") + + # Step 3: Verify our execution is in the response + # Find execution matching our trade (by symbol and approximate qty) + found_execution = None + for exec_item in executions.data: + if exec_item.symbol == spot_config.symbol: + found_execution = exec_item + break + + assert found_execution is not None, f"Should find execution for {spot_config.symbol}" + logger.info( + f"✅ Found execution: symbol={found_execution.symbol}, qty={found_execution.qty}, price={found_execution.price}" + ) + + # Verify execution fields + assert found_execution.symbol == spot_config.symbol + assert found_execution.qty is not None + assert found_execution.price is not None + assert found_execution.side is not None + + logger.info("✅ REST GET MARKET SPOT EXECUTIONS AFTER TRADE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_rest_get_market_spot_executions_invalid_symbol( + spot_config: SpotTestConfig, spot_tester: ReyaTester +): # pylint: disable=unused-argument + """ + Test REST API returns error for invalid symbol. + """ + logger.info("=" * 80) + logger.info("REST GET MARKET SPOT EXECUTIONS INVALID SYMBOL TEST") + logger.info("=" * 80) + + try: + await spot_tester.client.markets.get_market_spot_executions(symbol="INVALID_SYMBOL") + pytest.fail("Should have raised an error for invalid symbol") + except BadRequestException as e: + logger.info(f"✅ Correctly rejected invalid symbol: {e}") + + logger.info("✅ REST GET MARKET SPOT EXECUTIONS INVALID SYMBOL TEST COMPLETED") + + +# ============================================================================ +# WEBSOCKET TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_ws_market_spot_executions_realtime( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test WebSocket delivers real-time spot execution updates for market channel. + + Supports both empty and non-empty order books: + - If external bid liquidity exists, taker sells into it + - If no external liquidity, maker provides bid liquidity first + + Flow: + 1. Subscribe to market spot executions channel + 2. Check for external liquidity + 3. If needed, maker places GTC buy order + 4. Taker places IOC sell order + 5. Verify execution is received via WebSocket + """ + logger.info("=" * 80) + logger.info(f"WS MARKET SPOT EXECUTIONS REALTIME 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) + + # Step 1: Subscribe to market spot executions channel + maker_tester.ws.clear_market_spot_executions(spot_config.symbol) + maker_tester.ws.subscribe_to_market_spot_executions(spot_config.symbol) + + # Wait for subscription to be established + await asyncio.sleep(0.3) + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + maker_order_id = None + usable_bid_price = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid_price is not None: + # External bid liquidity exists - taker sells into it + fill_price = usable_bid_price + logger.info(f"Using external bid liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() + elif usable_ask_price is not None: + # External ask liquidity exists - taker buys from it + fill_price = usable_ask_price + logger.info(f"Using external ask liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).ioc().build() + else: + # No external liquidity - provide our own + fill_price = Decimal(str(spot_config.price(0.98))) + + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.98).gtc().build() + + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id} @ ${fill_price:.2f}") + + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() + + await taker_tester.orders.create_limit(taker_params) + logger.info("✅ Taker IOC order sent") + + # Wait for execution and WebSocket update + await asyncio.sleep(0.5) + if maker_order_id: + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Trade executed") + + # Step 3: Verify execution received via WebSocket + market_executions = list(maker_tester.ws.market_spot_executions.get(spot_config.symbol, [])) + + logger.info(f"Market spot executions received via WS: {len(market_executions)}") + + assert ( + len(market_executions) > 0 + ), f"Should have received market spot execution via WebSocket for {spot_config.symbol}" + + # Verify execution data + ws_execution = market_executions[-1] # Most recent + assert ws_execution.symbol == spot_config.symbol + logger.info(f"✅ WS execution: symbol={ws_execution.symbol}, qty={ws_execution.qty}, side={ws_execution.side}") + + logger.info("✅ WS MARKET SPOT EXECUTIONS REALTIME TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_ws_market_spot_executions_snapshot( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test WebSocket snapshot contains historical executions when subscribing. + + Flow: + 1. Execute a spot trade to create execution history (use external liquidity if available) + 2. Clear WebSocket state + 3. Subscribe to market spot executions channel + 4. Verify snapshot contains historical execution + """ + logger.info("=" * 80) + logger.info(f"WS MARKET SPOT EXECUTIONS SNAPSHOT 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) + + # Refresh order book state to check for external liquidity + await spot_config.refresh_order_book(taker_tester.data) + + # Step 1: Execute a trade to create execution history + # Use external liquidity if available, otherwise create our own maker order + usable_bid = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid is not None: + # External bid liquidity exists - taker sells into it + logger.info(f"Using external bid liquidity at ${usable_bid}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(usable_bid)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) # Wait for execution to settle + logger.info("✅ Trade executed against external bid - execution history created") + elif usable_ask is not None: + # External ask liquidity exists - taker buys into it + logger.info(f"Using external ask liquidity at ${usable_ask}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(usable_ask)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) # Wait for execution to settle + logger.info("✅ Trade executed against external ask - execution history created") + else: + # No external liquidity - create our own maker order + logger.info("No external liquidity - creating maker order") + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.98).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.98).ioc().build() + await taker_tester.orders.create_limit(taker_params) + + await asyncio.sleep(0.3) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Trade executed - execution history created") + + # Step 2: Clear WebSocket state and resubscribe + maker_tester.ws.clear_market_spot_executions(spot_config.symbol) + + # Step 3: Subscribe to market spot executions (should receive snapshot) + maker_tester.ws.subscribe_to_market_spot_executions(spot_config.symbol) + + # Wait for snapshot + await asyncio.sleep(0.5) + + # Step 4: Verify snapshot contains historical execution + market_executions = list(maker_tester.ws.market_spot_executions.get(spot_config.symbol, [])) + + logger.info(f"Market spot executions in snapshot: {len(market_executions)}") + + # Note: Snapshot may or may not include our specific execution depending on timing + # The key test is that we can subscribe and receive data + logger.info(f"✅ Received {len(market_executions)} execution(s) via WebSocket snapshot/updates") + + logger.info("✅ WS MARKET SPOT EXECUTIONS SNAPSHOT TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_ws_and_rest_market_spot_executions_consistency( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test that WebSocket and REST API return consistent data. + + Flow: + 1. Subscribe to market spot executions channel + 2. Execute a spot trade (use external liquidity if available) + 3. Query REST API + 4. Verify WebSocket and REST data are consistent + """ + logger.info("=" * 80) + logger.info(f"WS AND REST MARKET SPOT EXECUTIONS CONSISTENCY 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) + + # Step 1: Subscribe to market spot executions + taker_tester.ws.clear_market_spot_executions(spot_config.symbol) + taker_tester.ws.subscribe_to_market_spot_executions(spot_config.symbol) + await asyncio.sleep(0.3) + + # Refresh order book state to check for external liquidity + await spot_config.refresh_order_book(taker_tester.data) + + # Step 2: Execute a trade (use external liquidity if available) + usable_bid = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid is not None: + # External bid liquidity exists - taker sells into it + logger.info(f"Using external bid liquidity at ${usable_bid}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(usable_bid)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) # Wait for execution to settle + logger.info("✅ Trade executed against external bid") + elif usable_ask is not None: + # External ask liquidity exists - taker buys into it + logger.info(f"Using external ask liquidity at ${usable_ask}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(usable_ask)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) # Wait for execution to settle + logger.info("✅ Trade executed against external ask") + else: + # No external liquidity - create our own maker order + logger.info("No external liquidity - creating maker order") + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.98).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.98).ioc().build() + await taker_tester.orders.create_limit(taker_params) + + await asyncio.sleep(0.5) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Trade executed") + + # Step 3: Query REST API + rest_executions = await taker_tester.client.markets.get_market_spot_executions(symbol=spot_config.symbol) + + # Step 4: Verify consistency (use taker_tester since that's who subscribed) + ws_executions = list(taker_tester.ws.market_spot_executions.get(spot_config.symbol, [])) + + logger.info(f"REST executions: {len(rest_executions.data)}") + logger.info(f"WS executions: {len(ws_executions)}") + + # Both should have data + assert len(rest_executions.data) > 0, "REST should have executions" + assert len(ws_executions) > 0, "WS should have executions" + + # Compare most recent execution + rest_latest = rest_executions.data[0] # Assuming sorted by time desc + ws_latest = ws_executions[-1] # Most recent received + + logger.info(f"REST latest: symbol={rest_latest.symbol}, qty={rest_latest.qty}") + logger.info(f"WS latest: symbol={ws_latest.symbol}, qty={ws_latest.qty}") + + # Both should be for the same symbol + assert rest_latest.symbol == spot_config.symbol + assert ws_latest.symbol == spot_config.symbol + + logger.info("✅ WS AND REST MARKET SPOT EXECUTIONS CONSISTENCY TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.asyncio +async def test_ws_market_spot_executions_multiple_symbols( + spot_config: SpotTestConfig, spot_tester: ReyaTester +): # pylint: disable=unused-argument + """ + Test subscribing to multiple market spot execution channels. + + This test verifies that subscriptions to different symbols are independent. + """ + logger.info("=" * 80) + logger.info("WS MARKET SPOT EXECUTIONS MULTIPLE SYMBOLS TEST") + logger.info("=" * 80) + + symbols = ["WETHRUSD"] # Add more symbols when available + + # Subscribe to multiple symbols + for symbol in symbols: + spot_tester.ws.clear_market_spot_executions(symbol) + spot_tester.ws.subscribe_to_market_spot_executions(symbol) + logger.info(f"✅ Subscribed to {symbol}") + + await asyncio.sleep(0.3) + + # Verify subscriptions are independent + for symbol in symbols: + executions = list(spot_tester.ws.market_spot_executions.get(symbol, [])) + logger.info(f"{symbol}: {len(executions)} execution(s)") + + logger.info("✅ WS MARKET SPOT EXECUTIONS MULTIPLE SYMBOLS TEST COMPLETED") diff --git a/tests/test_spot/test_open_orders_rest.py b/tests/test_spot/test_open_orders_rest.py new file mode 100644 index 00000000..ad5fe379 --- /dev/null +++ b/tests/test_spot/test_open_orders_rest.py @@ -0,0 +1,243 @@ +""" +Open Orders REST API Tests + +Tests for the GET /v2/wallet/:address/openOrders endpoint: +- Fetching wallet open orders +- Empty orders list handling +- Order filtering by wallet +""" + +import asyncio +import logging + +import pytest + +from sdk.open_api.models.order import Order +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import OrderBuilder +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + + +@pytest.mark.spot +@pytest.mark.rest_api +@pytest.mark.asyncio +async def test_rest_get_open_orders_with_orders(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test open orders REST endpoint returns orders when they exist. + + Flow: + 1. Clear existing orders + 2. Place a GTC order (won't match immediately) + 3. Fetch open orders via REST + 4. Verify order appears in response + 5. Cleanup + """ + logger.info("=" * 80) + logger.info("OPEN ORDERS REST - WITH ORDERS TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Place a GTC order at a safe no-match price + await spot_config.refresh_order_book(spot_tester.data) + safe_price = spot_config.get_safe_no_match_buy_price() + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(safe_price)).gtc().build() + + logger.info(f"Placing GTC buy order at ${safe_price} (safe no-match price)") + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ Order created: {order_id}") + + # Fetch open orders via REST + open_orders: list[Order] = await spot_tester.client.get_open_orders() + + logger.info(f"Open orders returned: {len(open_orders)}") + + # Verify our order is in the list + assert len(open_orders) > 0, "Should have at least one open order" + + our_order = None + for order in open_orders: + if order.order_id == order_id: + our_order = order + break + + assert our_order is not None, f"Order {order_id} should be in open orders list" + logger.info("✅ Found our order in open orders list") + + # Verify order structure + assert isinstance(our_order, Order), f"Expected Order type, got {type(our_order)}" + assert our_order.symbol == spot_config.symbol, f"Expected symbol {spot_config.symbol}" + assert our_order.status == OrderStatus.OPEN, f"Expected OPEN status, got {our_order.status}" + + logger.info(f" Order ID: {our_order.order_id}") + logger.info(f" Symbol: {our_order.symbol}") + logger.info(f" Status: {our_order.status}") + + # Cleanup + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + + logger.info("✅ OPEN ORDERS REST - WITH ORDERS TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.rest_api +@pytest.mark.asyncio +async def test_rest_get_open_orders_empty(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test open orders REST endpoint returns empty list when no orders exist. + + Flow: + 1. Cancel all existing orders + 2. Fetch open orders via REST + 3. Verify empty list is returned + """ + logger.info("=" * 80) + logger.info("OPEN ORDERS REST - EMPTY TEST") + logger.info("=" * 80) + + # Cancel all existing orders + await spot_tester.orders.close_all(fail_if_none=False) + await asyncio.sleep(0.1) + + # Fetch open orders via REST + open_orders: list[Order] = await spot_tester.client.get_open_orders() + + logger.info(f"Open orders returned: {len(open_orders)}") + + # Filter to only our account's orders for the spot symbol + our_orders = [o for o in open_orders if o.account_id == spot_tester.account_id and o.symbol == spot_config.symbol] + + assert len(our_orders) == 0, f"Should have no open orders for {spot_config.symbol}, got {len(our_orders)}" + logger.info(f"✅ No open orders for account {spot_tester.account_id} on {spot_config.symbol}") + + logger.info("✅ OPEN ORDERS REST - EMPTY TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.rest_api +@pytest.mark.asyncio +async def test_rest_get_open_orders_multiple(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test open orders REST endpoint returns multiple orders correctly. + + Flow: + 1. Clear existing orders + 2. Place multiple GTC orders at different prices + 3. Fetch open orders via REST + 4. Verify all orders appear in response + 5. Cleanup + """ + logger.info("=" * 80) + logger.info("OPEN ORDERS REST - MULTIPLE ORDERS TEST") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Place multiple GTC orders at safe no-match prices + await spot_config.refresh_order_book(spot_tester.data) + base_price = float(spot_config.get_safe_no_match_buy_price()) + + num_orders = 3 + order_ids = [] + + for i in range(num_orders): + price = base_price + i # Slightly different prices + order_params = OrderBuilder.from_config(spot_config).buy().price(str(price)).gtc().build() + + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + order_ids.append(order_id) + logger.info(f"✅ Order {i + 1}/{num_orders} created: {order_id} @ ${price}") + + # Fetch open orders via REST + open_orders: list[Order] = await spot_tester.client.get_open_orders() + + logger.info(f"Total open orders returned: {len(open_orders)}") + + # Verify all our orders are in the list + found_orders = [o for o in open_orders if o.order_id in order_ids] + assert len(found_orders) == num_orders, f"Expected {num_orders} orders, found {len(found_orders)}" + logger.info(f"✅ All {num_orders} orders found in open orders list") + + # Cleanup + for order_id in order_ids: + try: + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + except (OSError, RuntimeError): + pass + + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + + logger.info("✅ OPEN ORDERS REST - MULTIPLE ORDERS TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.rest_api +@pytest.mark.asyncio +async def test_rest_get_open_orders_filters_by_wallet( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test open orders are filtered by wallet address. + + Flow: + 1. Maker places a GTC order + 2. Taker fetches open orders + 3. Verify maker's order does NOT appear in taker's list + 4. Maker fetches open orders + 5. Verify maker's order DOES appear in maker's list + """ + logger.info("=" * 80) + logger.info("OPEN ORDERS REST - FILTER BY WALLET TEST") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Maker places a GTC order + await spot_config.refresh_order_book(maker_tester.data) + safe_price = spot_config.get_safe_no_match_buy_price() + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(safe_price)).gtc().build() + + logger.info(f"Maker placing GTC buy order at ${safe_price}") + maker_order_id = await maker_tester.orders.create_limit(order_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id}") + + # Taker fetches open orders + taker_orders: list[Order] = await taker_tester.client.get_open_orders() + taker_order_ids = [o.order_id for o in taker_orders] + + assert ( + maker_order_id not in taker_order_ids + ), f"Maker's order {maker_order_id} should NOT appear in taker's open orders" + logger.info("✅ Maker's order does NOT appear in taker's open orders") + + # Maker fetches open orders + maker_orders: list[Order] = await maker_tester.client.get_open_orders() + maker_order_ids = [o.order_id for o in maker_orders] + + assert maker_order_id in maker_order_ids, f"Maker's order {maker_order_id} should appear in maker's open orders" + logger.info("✅ Maker's order DOES appear in maker's open orders") + + # Cleanup + await maker_tester.client.cancel_order( + order_id=maker_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id + ) + await asyncio.sleep(0.1) + await maker_tester.check.no_open_orders() + + logger.info("✅ OPEN ORDERS REST - FILTER BY WALLET TEST COMPLETED") diff --git a/tests/test_spot/test_order_book.py b/tests/test_spot/test_order_book.py new file mode 100644 index 00000000..fa1c451d --- /dev/null +++ b/tests/test_spot/test_order_book.py @@ -0,0 +1,281 @@ +""" +Tests for spot order book (L2 depth) verification. + +These tests verify that orders appear correctly in the order book +and that depth updates are received via WebSocket. + +These tests use safe no-match prices to ensure orders are added to the book +without matching existing liquidity. +""" + +import asyncio + +import pytest + +from sdk.open_api.models.depth import Depth +from sdk.open_api.models.level import Level +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.reya_tester import logger +from tests.test_spot.spot_config import SpotTestConfig + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.asyncio +async def test_spot_order_appears_in_depth(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that a GTC order appears in the L2 order book depth. + + Uses a safe no-match price to ensure the order is added to the book. + + Flow: + 1. Subscribe to market depth + 2. Get safe no-match price + 3. Place GTC order at that price + 4. Verify order appears in L2 depth via REST + 5. Cancel order + 6. Verify order removed from depth + """ + logger.info("=" * 80) + logger.info(f"SPOT ORDER BOOK TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Clear any existing orders + await spot_tester.check.no_open_orders() + + # Subscribe to market depth + spot_tester.ws.subscribe_to_market_depth(spot_config.symbol) + await asyncio.sleep(0.05) + + # Get initial depth and determine safe no-match price + await spot_config.refresh_order_book(spot_tester.data) + initial_depth = await spot_tester.data.market_depth(spot_config.symbol) + assert isinstance(initial_depth, Depth), f"Expected Depth type, got {type(initial_depth)}" + initial_bid_count = len(initial_depth.bids) + logger.info(f"Initial depth: {initial_bid_count} bids") + + # Get a buy price guaranteed not to match (below all asks) + order_price = spot_config.get_safe_no_match_buy_price() + logger.info(f"Safe no-match buy price: ${order_price:.2f}") + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(order_price)).gtc().build() + + logger.info(f"Placing GTC buy at ${order_price:.2f}...") + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ Order created: {order_id}") + + # Wait for depth to update + await asyncio.sleep(0.1) + + # Get updated depth + updated_depth = await spot_tester.data.market_depth(spot_config.symbol) + assert isinstance(updated_depth, Depth), f"Expected Depth type, got {type(updated_depth)}" + bids = updated_depth.bids + + # Find our order in bids (using typed Level.px attribute) + order_found = False + for bid in bids: + assert isinstance(bid, Level), f"Expected Level type, got {type(bid)}" + bid_price = float(bid.px) + if abs(bid_price - float(order_price)) < 0.01: + order_found = True + bid_qty = float(bid.qty) + logger.info(f"✅ Found order in depth: ${bid_price:.2f} x {bid_qty}") + break + + assert order_found, f"Order at ${order_price:.2f} not found in L2 depth" + + # Cancel the order + logger.info("Cancelling order...") + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await spot_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED) + + # Wait for depth to update + await asyncio.sleep(0.1) + + # Verify order removed from depth + final_depth = await spot_tester.data.market_depth(spot_config.symbol) + final_bids = final_depth.bids + + order_still_present = False + for bid in final_bids: + bid_price = float(bid.px) + if abs(bid_price - float(order_price)) < 0.01: + order_still_present = True + break + + assert not order_still_present, f"Order at ${order_price:.2f} should be removed from depth" + logger.info("✅ Order removed from depth after cancellation") + + logger.info("✅ SPOT ORDER BOOK TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_spot_multiple_orders_aggregate_in_depth(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that multiple orders at the same price aggregate in depth. + + Uses a safe no-match price to ensure orders are added to the book. + + Flow: + 1. Get safe no-match price + 2. Place two orders at the same price + 3. Verify depth shows aggregated quantity + 4. Cancel both orders + """ + logger.info("=" * 80) + logger.info(f"SPOT DEPTH AGGREGATION TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Clear any existing orders + await spot_tester.check.no_open_orders() + + # Get safe no-match price + await spot_config.refresh_order_book(spot_tester.data) + order_price = spot_config.get_safe_no_match_buy_price() + logger.info(f"Safe no-match buy price: ${order_price:.2f}") + + qty_per_order = spot_config.min_qty + + order_ids = [] + for i in range(2): + order_params = OrderBuilder.from_config(spot_config).buy().price(str(order_price)).gtc().build() + + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + order_ids.append(order_id) + logger.info(f"✅ Order {i + 1} created: {order_id}") + + # Wait for depth to update + await asyncio.sleep(0.1) + + # Get depth and verify aggregation + depth = await spot_tester.data.market_depth(spot_config.symbol) + assert isinstance(depth, Depth), f"Expected Depth type, got {type(depth)}" + bids = depth.bids + + aggregated_qty = None + for bid in bids: + bid_price = float(bid.px) + if abs(bid_price - float(order_price)) < 0.01: + aggregated_qty = float(bid.qty) + break + + expected_total = float(qty_per_order) * 2 + assert aggregated_qty is not None, f"No bid found at ${order_price:.2f}" + assert ( + abs(aggregated_qty - expected_total) < 0.0001 + ), f"Aggregated qty should be {expected_total}, got {aggregated_qty}" + logger.info(f"✅ Depth shows aggregated qty: {aggregated_qty} (expected {expected_total})") + + # Cleanup - cancel all orders + for order_id in order_ids: + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT DEPTH AGGREGATION TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_bid_ask_spread(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): + """ + Test placing both bid and ask orders to create a spread. + + Flow: + 1. Maker places buy order (bid) at low price + 2. Taker places sell order (ask) at high price (taker has more ETH) + 3. Verify both appear in depth + 4. Verify bid < ask (proper spread) + + Uses maker/taker fixtures to ensure sufficient balances for both sides. + Uses prices far from market to avoid matching existing liquidity. + """ + logger.info("=" * 80) + logger.info(f"SPOT BID/ASK SPREAD TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Clear any existing orders (fail_if_none=False since we're just cleaning up) + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Use prices far from market to avoid matching existing liquidity + bid_price = spot_config.price(0.96) # 50% below reference + + bid_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() + + logger.info(f"Maker placing bid at ${bid_price:.2f}...") + bid_order_id = await maker_tester.orders.create_limit(bid_params) + await maker_tester.wait.for_order_creation(bid_order_id) + logger.info(f"✅ Bid order created: {bid_order_id}") + + # Taker places ask (sell) order at high price (taker has more ETH balance) + ask_price = spot_config.price(1.04) # 50% above reference + + ask_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() + + logger.info(f"Taker placing ask at ${ask_price:.2f}...") + ask_order_id = await taker_tester.orders.create_limit(ask_params) + await taker_tester.wait.for_order_creation(ask_order_id) + logger.info(f"✅ Ask order created: {ask_order_id}") + + # Wait for depth to update + await asyncio.sleep(0.1) + + # Get depth + depth = await maker_tester.data.market_depth(spot_config.symbol) + assert isinstance(depth, Depth), f"Expected Depth type, got {type(depth)}" + bids = depth.bids + asks = depth.asks + + logger.info(f"Depth: {len(bids)} bids, {len(asks)} asks") + + # Find our orders (using typed Level.px attribute) + our_bid = None + our_ask = None + + for bid in bids: + price = float(bid.px) + if abs(price - bid_price) < 1.0: # Allow some tolerance + our_bid = price + logger.info(f"Found our bid at ${price:.2f}") + break + + for ask in asks: + price = float(ask.px) + if abs(price - ask_price) < 1.0: # Allow some tolerance + our_ask = price + logger.info(f"Found our ask at ${price:.2f}") + break + + assert our_bid is not None, f"Bid at ${bid_price:.2f} not found in depth" + assert our_ask is not None, f"Ask at ${ask_price:.2f} not found in depth" + assert our_bid < our_ask, f"Bid ({our_bid}) should be less than ask ({our_ask})" + + spread = our_ask - our_bid + logger.info(f"✅ Spread verified: bid=${our_bid:.2f}, ask=${our_ask:.2f}, spread=${spread:.2f}") + + # Cleanup + await maker_tester.client.cancel_order( + order_id=bid_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id + ) + await taker_tester.client.cancel_order( + order_id=ask_order_id, symbol=spot_config.symbol, account_id=taker_tester.account_id + ) + + await asyncio.sleep(0.05) + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT BID/ASK SPREAD TEST COMPLETED") diff --git a/tests/test_spot/test_order_cancellation.py b/tests/test_spot/test_order_cancellation.py new file mode 100644 index 00000000..885c55d4 --- /dev/null +++ b/tests/test_spot/test_order_cancellation.py @@ -0,0 +1,451 @@ +""" +Tests for spot order cancellation. + +These tests verify single order cancellation and mass cancel functionality +for spot markets. +""" + +import asyncio +import time + +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.reya_tester import limit_order_params_to_order, logger +from tests.test_spot.spot_config import SpotTestConfig + + +@pytest.mark.spot +@pytest.mark.cancel +@pytest.mark.asyncio +async def test_spot_order_cancellation(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test placing and cancelling a spot GTC order before it gets filled. + """ + logger.info("=" * 80) + logger.info(f"SPOT ORDER CANCELLATION TEST: {spot_config.symbol}") + logger.info("=" * 80) + logger.info(f"Using reference price for orders: ${spot_config.oracle_price}") + + # Clear any existing orders + await spot_tester.check.no_open_orders() + + # Place GTC order far from reference (won't fill) + buy_price = spot_config.price(0.96) # Far below reference + + order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() + + logger.info(f"Placing GTC buy order at ${buy_price:.2f} (far from market)...") + order_id = await spot_tester.orders.create_limit(order_params) + logger.info(f"Created order with ID: {order_id}") + + # Wait for order creation + await spot_tester.wait.for_order_creation(order_id) + expected_order = limit_order_params_to_order(order_params, spot_tester.account_id) + await spot_tester.check.open_order_created(order_id, expected_order) + logger.info("✅ Order confirmed on the book") + + # Cancel the order + logger.info("Cancelling order...") + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + + # Wait for cancellation confirmation + cancelled_order_id = await spot_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED) + assert cancelled_order_id == order_id, "Order was not cancelled" + logger.info("✅ Order cancelled successfully") + + # Verify no open orders remain + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT ORDER CANCELLATION TEST COMPLETED SUCCESSFULLY") + + +@pytest.mark.spot +@pytest.mark.cancel +@pytest.mark.asyncio +async def test_spot_mass_cancel(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test placing multiple spot GTC orders and then cancelling them all via mass cancel. + """ + num_orders = 5 + + logger.info("=" * 80) + logger.info(f"SPOT MASS CANCEL TEST: {spot_config.symbol}") + logger.info("=" * 80) + logger.info(f"Using reference price for orders: ${spot_config.oracle_price}") + + # Clear any existing orders + await spot_tester.check.no_open_orders() + + # Place multiple GTC orders at different prices (far from market, won't fill) + order_ids = [] + + logger.info(f"\n📋 Step 1: Placing {num_orders} GTC orders...") + for i in range(num_orders): + # Space orders within 5% of oracle price (0.96 to 0.98) + price_factor = 0.96 + (i * 0.005) + buy_price = round(spot_config.oracle_price * price_factor, 2) + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(buy_price)).gtc().build() + + logger.info(f"Creating order {i + 1}/{num_orders} at ${buy_price:.2f}") + order_id = await spot_tester.orders.create_limit(order_params) + order_ids.append(order_id) + + # Wait for order creation + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ Order {i + 1} created: {order_id}") + + logger.info(f"\n✅ All {num_orders} orders created successfully") + + # Verify all orders are on the book + logger.info("\n📊 Step 2: Verifying all orders are on the book...") + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + + for order_id in order_ids: + assert order_id in open_order_ids, f"Order {order_id} not found on the book" + + logger.info(f"✅ Verified {len(order_ids)} orders on the book") + + # Mass cancel all orders for this symbol + logger.info("\n🧹 Step 3: Mass cancelling all orders...") + response = await spot_tester.client.mass_cancel(symbol=spot_config.symbol, account_id=spot_tester.account_id) + logger.info(f"Mass cancel response: {response}") + + # Wait a moment for cancellations to propagate + await asyncio.sleep(0.1) + + # Verify all orders are cancelled + logger.info("\n📊 Step 4: Verifying all orders are cancelled...") + open_orders_after = await spot_tester.client.get_open_orders() + open_order_ids_after = [o.order_id for o in open_orders_after if o.symbol == spot_config.symbol] + + for order_id in order_ids: + assert order_id not in open_order_ids_after, f"Order {order_id} still exists after mass cancel" + + logger.info(f"✅ All {num_orders} orders successfully cancelled via mass cancel") + + # Final verification - no open orders remain + await spot_tester.check.no_open_orders() + + logger.info("\n%s", "=" * 80) + logger.info("✅ SPOT MASS CANCEL TEST COMPLETED SUCCESSFULLY") + logger.info("=" * 80) + + +@pytest.mark.spot +@pytest.mark.cancel +@pytest.mark.asyncio +async def test_spot_cancel_nonexistent_order(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test cancelling an order that doesn't exist. + + Flow: + 1. Attempt to cancel a non-existent order ID + 2. Verify error response is returned + """ + logger.info("=" * 80) + logger.info(f"SPOT CANCEL NONEXISTENT ORDER TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Use a fake order ID that doesn't exist + fake_order_id = "999999999999999999" + + logger.info(f"Attempting to cancel non-existent order: {fake_order_id}") + + try: + await spot_tester.client.cancel_order( + order_id=fake_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + # If we get here, the API might accept the request but do nothing + logger.info("Cancel request accepted (order may not exist)") + except ApiException as e: + logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") + + logger.info("✅ SPOT CANCEL NONEXISTENT ORDER TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.cancel +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_cancel_already_filled_order( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test cancelling an order that was already filled. + + Flow: + 1. Maker places GTC sell order (maker has more ETH) + 2. Taker buys with IOC (taker has more RUSD) + 3. Attempt to cancel the filled order + 4. Verify error response (order already filled) + """ + # Check current order book state + await spot_config.refresh_order_book(taker_tester.data) + + logger.info("=" * 80) + logger.info(f"SPOT CANCEL ALREADY FILLED ORDER TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Determine how to get a filled order based on liquidity + if spot_config.has_usable_ask_liquidity: + # External asks exist - taker buys from external + ask_price = spot_config.best_ask_price + assert ask_price is not None + trade_price = float(ask_price) + logger.info(f"Using external ask liquidity at ${trade_price:.2f}") + + # Place GTC buy order that will match external asks + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(ask_price)).gtc().build() + filled_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker placing GTC buy: {spot_config.min_qty} @ ${trade_price:.2f}") + + # Wait for fill + await taker_tester.wait.for_order_state(filled_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Order filled by external liquidity") + + # Now try to cancel the already-filled order + logger.info(f"Attempting to cancel already-filled order: {filled_order_id}") + + try: + await taker_tester.client.cancel_order( + order_id=filled_order_id, symbol=spot_config.symbol, account_id=taker_tester.account_id + ) + logger.info("Cancel request accepted (order already filled)") + except ApiException as e: + logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") + elif spot_config.has_usable_bid_liquidity: + # External bids exist - taker sells to external + bid_price = spot_config.best_bid_price + assert bid_price is not None + trade_price = float(bid_price) + logger.info(f"Using external bid liquidity at ${trade_price:.2f}") + + # Place GTC sell order that will match external bids + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(bid_price)).gtc().build() + filled_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${trade_price:.2f}") + + # Wait for fill + await taker_tester.wait.for_order_state(filled_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Order filled by external liquidity") + + # Now try to cancel the already-filled order + logger.info(f"Attempting to cancel already-filled order: {filled_order_id}") + + try: + await taker_tester.client.cancel_order( + order_id=filled_order_id, symbol=spot_config.symbol, account_id=taker_tester.account_id + ) + logger.info("Cancel request accepted (order already filled)") + except ApiException as e: + logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") + else: + # No external liquidity - use maker-taker matching + maker_price = spot_config.price(1.04) + + maker_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() + logger.info(f"Maker placing GTC sell: {spot_config.min_qty} @ ${maker_price:.2f}") + filled_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(filled_order_id) + logger.info(f"✅ Maker order created: {filled_order_id}") + + # Taker buys with IOC + taker_params = OrderBuilder.from_config(spot_config).buy().at_price(1.04).ioc().build() + logger.info("Taker placing IOC buy to fill maker order...") + await taker_tester.orders.create_limit(taker_params) + + # Wait for fill + await asyncio.sleep(0.05) + await maker_tester.wait.for_order_state(filled_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Maker order filled") + + # Now try to cancel the already-filled order + logger.info(f"Attempting to cancel already-filled order: {filled_order_id}") + + try: + await maker_tester.client.cancel_order( + order_id=filled_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id + ) + logger.info("Cancel request accepted (order already filled)") + except ApiException as e: + logger.info(f"✅ Cancel rejected as expected: {type(e).__name__}") + + # Verify no open orders + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT CANCEL ALREADY FILLED ORDER TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.cancel +@pytest.mark.asyncio +async def test_spot_mass_cancel_empty_book(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test mass cancel when no orders exist. + + Flow: + 1. Ensure no orders exist + 2. Call mass cancel + 3. Verify success with count=0 (or no error) + """ + logger.info("=" * 80) + logger.info(f"SPOT MASS CANCEL EMPTY BOOK TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Ensure no orders exist + await spot_tester.orders.close_all(fail_if_none=False) + await spot_tester.check.no_open_orders() + + logger.info("Calling mass cancel on empty book...") + + try: + response = await spot_tester.client.mass_cancel(symbol=spot_config.symbol, account_id=spot_tester.account_id) + logger.info(f"✅ Mass cancel succeeded: {response}") + except ApiException as e: + # Some APIs might return an error for empty cancel + logger.info(f"Mass cancel response: {type(e).__name__}") + + # Verify still no orders + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT MASS CANCEL EMPTY BOOK TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.cancel +@pytest.mark.asyncio +async def test_spot_cancel_by_client_order_id(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test cancelling an order using client order ID instead of order ID. + + Flow: + 1. Place GTC order with a specific client order ID + 2. Cancel the order using the client order ID + 3. Verify order is cancelled + """ + logger.info("=" * 80) + logger.info(f"SPOT CANCEL BY CLIENT ORDER ID TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Generate a unique client order ID (must be an integer) + client_order_id = int(time.time() * 1000) % (2**31 - 1) # Keep within int32 range + + # Place GTC order with client order ID + order_price = spot_config.price(0.96) + + order_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .buy() + .price(str(order_price)) + .qty(spot_config.min_qty) + .gtc() + .client_order_id(client_order_id) + .build() + ) + + logger.info(f"Placing GTC order with clientOrderId: {client_order_id}") + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ Order created: {order_id}") + + # Verify order is on the book + open_orders = await spot_tester.client.get_open_orders() + order_found = False + for o in open_orders: + if o.order_id == order_id: + order_found = True + if hasattr(o, "client_order_id") and o.client_order_id: + logger.info(f"Order has clientOrderId: {o.client_order_id}") + break + + assert order_found, f"Order {order_id} not found on the book" + + # Cancel using client order ID + logger.info(f"Cancelling order using clientOrderId: {client_order_id}") + + try: + await spot_tester.client.cancel_order( + order_id=order_id, # Some APIs require order_id even with client_order_id + symbol=spot_config.symbol, + account_id=spot_tester.account_id, + client_order_id=client_order_id, + ) + logger.info("✅ Cancel request sent with clientOrderId") + except TypeError: + # If client_order_id is not supported, fall back to order_id + logger.info("clientOrderId not supported in cancel, using order_id") + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + + # Wait for cancellation + await spot_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED) + logger.info("✅ Order cancelled successfully") + + # Verify no open orders + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT CANCEL BY CLIENT ORDER ID TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.cancel +@pytest.mark.asyncio +async def test_spot_mass_cancel_no_orders(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test mass cancel when no orders exist for the account/market. + + Flow: + 1. Ensure no open orders exist + 2. Execute mass cancel + 3. Verify response indicates 0 orders cancelled + 4. Verify no errors are raised + """ + logger.info("=" * 80) + logger.info(f"SPOT MASS CANCEL NO ORDERS TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Ensure no open orders exist + await spot_tester.orders.close_all(fail_if_none=False) + await spot_tester.check.no_open_orders() + logger.info("✅ Confirmed no open orders exist") + + # Execute mass cancel on empty order book (for this account) + logger.info("Executing mass cancel with no orders...") + response = await spot_tester.client.mass_cancel(symbol=spot_config.symbol, account_id=spot_tester.account_id) + + # Verify response + logger.info(f"Mass cancel response: {response}") + + # The response should indicate 0 orders were cancelled + if hasattr(response, "cancelled_count"): + assert response.cancelled_count == 0, f"Expected 0 cancelled orders, got {response.cancelled_count}" + logger.info("✅ Response correctly shows 0 orders cancelled") + elif hasattr(response, "cancelledCount"): + assert response.cancelledCount == 0, f"Expected 0 cancelled orders, got {response.cancelledCount}" + logger.info("✅ Response correctly shows 0 orders cancelled") + else: + # If response doesn't have count, just verify no error was raised + logger.info("✅ Mass cancel succeeded without error (no count in response)") + + # Verify still no open orders + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT MASS CANCEL NO ORDERS TEST COMPLETED") diff --git a/tests/test_spot/test_response_validation.py b/tests/test_spot/test_response_validation.py new file mode 100644 index 00000000..789c892e --- /dev/null +++ b/tests/test_spot/test_response_validation.py @@ -0,0 +1,742 @@ +""" +Response Validation Tests + +Tests for validating the structure and data types of API responses: +- Order response fields validation +- Spot execution response fields validation +- Depth levels structure validation +""" + +import asyncio +import logging +from decimal import Decimal + +import pytest + +from sdk.open_api.models import OrderStatus +from sdk.open_api.models.depth import Depth +from sdk.open_api.models.level import Level +from sdk.open_api.models.order import Order +from sdk.open_api.models.spot_execution import SpotExecution +from sdk.open_api.models.spot_execution_list import SpotExecutionList +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import OrderBuilder +from tests.helpers.validators import validate_order_fields, validate_spot_execution_fields +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + + +# ============================================================================= +# ORDER RESPONSE VALIDATION +# ============================================================================= + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_order_response_fields_gtc(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test GTC order response contains all required fields with correct types. + + Validates: + - exchange_id: int + - symbol: str + - account_id: int + - order_id: str (non-empty) + - qty: str (numeric) + - side: Side enum + - limit_px: str (numeric) + - order_type: OrderType enum + - time_in_force: TimeInForce enum + - status: OrderStatus enum + - created_at: int (timestamp) + - last_update_at: int (timestamp) + """ + logger.info("=" * 80) + logger.info("ORDER RESPONSE FIELDS VALIDATION - GTC") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Place a GTC order at safe no-match price + await spot_config.refresh_order_book(spot_tester.data) + safe_price = spot_config.get_safe_no_match_buy_price() + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(safe_price)).gtc().build() + + logger.info(f"Placing GTC buy order at ${safe_price}") + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + + # Fetch the order via REST + open_orders: list[Order] = await spot_tester.client.get_open_orders() + order = next((o for o in open_orders if o.order_id == order_id), None) + + assert order is not None, f"Order {order_id} not found in open orders" + + # Validate required fields and types using shared validator + validate_order_fields(order, expected_symbol=spot_config.symbol, is_gtc=True, log_details=True) + + logger.info("✅ All GTC order fields validated successfully") + + # Cleanup + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.1) + + logger.info("✅ ORDER RESPONSE FIELDS VALIDATION - GTC COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_order_response_fields_after_partial_fill( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test order response fields after partial fill. + + This test requires a controlled environment. + When external liquidity exists, we skip. + + Validates: + - exec_qty: str (filled quantity) + - cum_qty: str (cumulative filled quantity) + - status: PARTIALLY_FILLED or OPEN + """ + logger.info("=" * 80) + logger.info("ORDER RESPONSE FIELDS VALIDATION - PARTIAL FILL") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping partial fill test: external liquidity exists. This test requires a controlled environment." + ) + + # Place a larger maker order + maker_qty = str(Decimal(spot_config.min_qty) * 3) + maker_price = spot_config.price(0.97) + + maker_params = OrderBuilder.from_config(spot_config).buy().price(str(maker_price)).qty(maker_qty).gtc().build() + + logger.info(f"Maker placing GTC buy: {maker_qty} @ ${maker_price}") + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + + # Taker partially fills with smaller quantity + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(maker_price)).ioc().build() + + logger.info(f"Taker placing IOC sell: {spot_config.min_qty} @ ${maker_price}") + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.3) + + # Fetch maker's order + maker_orders: list[Order] = await maker_tester.client.get_open_orders() + maker_order = next((o for o in maker_orders if o.order_id == maker_order_id), None) + + if maker_order is not None: + # Order still open (partially filled) + logger.info(f"Order status: {maker_order.status}") + logger.info(f"exec_qty: {maker_order.exec_qty}") + logger.info(f"cum_qty: {maker_order.cum_qty}") + + # Validate exec_qty and cum_qty are present and numeric + if maker_order.exec_qty is not None: + assert _is_numeric_string(maker_order.exec_qty), f"exec_qty should be numeric: {maker_order.exec_qty}" + logger.info("✅ exec_qty is valid numeric string") + + if maker_order.cum_qty is not None: + assert _is_numeric_string(maker_order.cum_qty), f"cum_qty should be numeric: {maker_order.cum_qty}" + logger.info("✅ cum_qty is valid numeric string") + + # Cleanup remaining order + await maker_tester.client.cancel_order( + order_id=maker_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id + ) + else: + logger.info("Order fully filled (not in open orders)") + + await asyncio.sleep(0.1) + + logger.info("✅ ORDER RESPONSE FIELDS VALIDATION - PARTIAL FILL COMPLETED") + + +# ============================================================================= +# SPOT EXECUTION RESPONSE VALIDATION +# ============================================================================= + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_execution_response_fields( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test spot execution response contains all required fields with correct types. + + Validates: + - exchange_id: int (optional) + - symbol: str + - account_id: int + - maker_account_id: int + - order_id: str (optional) + - maker_order_id: str (optional) + - side: Side enum + - qty: str (numeric, positive) + - price: str (numeric, positive) + - fee: str (numeric) + - type: ExecutionType enum + - timestamp: int (unix timestamp) + """ + logger.info("=" * 80) + logger.info("SPOT EXECUTION RESPONSE FIELDS VALIDATION") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Refresh order book state + await spot_config.refresh_order_book(taker_tester.data) + + # Execute a trade + usable_bid = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + maker_order_id = None + + if usable_bid is not None: + logger.info(f"Using external bid liquidity at ${usable_bid}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(usable_bid)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) + elif usable_ask is not None: + logger.info(f"Using external ask liquidity at ${usable_ask}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(usable_ask)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) + else: + logger.info("No external liquidity - creating maker order") + 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(0.3) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + + logger.info("✅ Trade executed") + + # Fetch spot executions + assert taker_tester.owner_wallet_address is not None, "Taker wallet address should not be None" + executions: SpotExecutionList = await taker_tester.client.wallet.get_wallet_spot_executions( + address=taker_tester.owner_wallet_address + ) + + assert len(executions.data) > 0, "Should have at least one execution" + + # Validate the latest execution using shared validator + execution: SpotExecution = executions.data[0] + validate_spot_execution_fields(execution, expected_symbol=spot_config.symbol, log_details=True) + + logger.info("✅ All spot execution fields validated successfully") + logger.info("✅ SPOT EXECUTION RESPONSE FIELDS VALIDATION COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_execution_side_correctness( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test spot execution side field reflects the correct trade direction. + + This test requires a controlled environment. + When external liquidity exists, we skip. + + Flow: + 1. Maker places buy order + 2. Taker sells into it + 3. Verify taker's execution shows correct side + """ + logger.info("=" * 80) + logger.info("SPOT EXECUTION SIDE CORRECTNESS VALIDATION") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping side correctness test: external liquidity exists. This test requires a controlled environment." + ) + + # Maker places buy order + maker_price = spot_config.price(0.97) + maker_params = OrderBuilder.from_config(spot_config).buy().price(str(maker_price)).gtc().build() + + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + + # Taker sells into it + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(maker_price)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.3) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + + # Fetch taker's executions + assert taker_tester.owner_wallet_address is not None, "Taker wallet address should not be None" + executions: SpotExecutionList = await taker_tester.client.wallet.get_wallet_spot_executions( + address=taker_tester.owner_wallet_address + ) + + assert len(executions.data) > 0, "Should have at least one execution" + + latest = executions.data[0] + + # Taker was selling, so side should be A (Ask/Sell) + assert latest.side.value == "A", f"Taker sold, expected side=A (Ask), got {latest.side}" + logger.info(f"✅ Taker execution side is correct: {latest.side}") + + logger.info("✅ SPOT EXECUTION SIDE CORRECTNESS VALIDATION COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_spot_execution_maker_vs_taker_fields( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test that maker and taker see consistent execution data with correct account references. + + This test works with both empty and non-empty order books by always creating + our own maker order at a price that will be matched by our taker. + + Validates: + - Taker execution: account_id = taker, order_id = taker's order + - Maker execution: account_id = maker, order_id = maker's order + - Both have same maker_account_id and maker_order_id (pointing to maker) + - Both have matching qty, price, symbol + """ + logger.info("=" * 80) + logger.info("SPOT EXECUTION MAKER VS TAKER FIELDS VALIDATION") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + await spot_config.refresh_order_book(maker_tester.data) + + # Determine the best price for our maker order + # We need to ensure our maker order is at the BEST price so taker matches with us + # Check both bid and ask liquidity to decide which direction to trade + best_external_bid = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + best_external_ask = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + logger.info(f"External liquidity: bid={best_external_bid}, ask={best_external_ask}") + + # Decide trade direction based on what external liquidity exists + # Case 1: No external liquidity - default to maker buys + # Case 2: Only bids exist - maker buys at better price than external bids + # Case 3: Only asks exist - maker sells at better price than external asks + # Case 4: Both exist - maker buys at better price than external bids + if best_external_ask is not None and best_external_bid is None: + # Only external asks exist - maker places SELL, taker BUYS + # Place our ask BELOW the best external ask so we get matched first + maker_price = round(float(best_external_ask) * 0.999, 2) + logger.info(f"Only external asks exist - placing maker SELL at ${maker_price} (below ${best_external_ask})") + + maker_params = OrderBuilder.from_config(spot_config).sell().price(str(maker_price)).gtc().build() + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(maker_price)).ioc().build() + else: + # No external liquidity, only bids, or both - maker places BUY, taker SELLS + if best_external_bid is not None: + # Place our bid ABOVE the best external bid so we get matched first + maker_price = round(float(best_external_bid) * 1.001, 2) + logger.info(f"External bids exist - placing maker BUY at ${maker_price} (above ${best_external_bid})") + else: + maker_price = spot_config.price(0.99) + logger.info(f"No external liquidity - placing maker BUY at ${maker_price}") + + maker_params = OrderBuilder.from_config(spot_config).buy().price(str(maker_price)).gtc().build() + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(maker_price)).ioc().build() + + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id}") + + taker_response = await taker_tester.orders.create_limit(taker_params) + taker_order_id = taker_response + logger.info(f"Taker order sent: {taker_order_id}") + + # Wait for maker order to be filled + await asyncio.sleep(0.3) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Trade executed") + + # Fetch executions from BOTH wallets + await asyncio.sleep(0.3) # Allow indexing + + assert taker_tester.owner_wallet_address is not None, "Taker wallet address should not be None" + assert maker_tester.owner_wallet_address is not None, "Maker wallet address should not be None" + taker_executions: SpotExecutionList = await taker_tester.client.wallet.get_wallet_spot_executions( + address=taker_tester.owner_wallet_address + ) + maker_executions: SpotExecutionList = await maker_tester.client.wallet.get_wallet_spot_executions( + address=maker_tester.owner_wallet_address + ) + + assert len(taker_executions.data) > 0, "Taker should have at least one execution" + assert len(maker_executions.data) > 0, "Maker should have at least one execution" + + # Find the matching executions + # Taker's execution: order_id = taker's order + # Maker's execution: maker_order_id = maker's order (the order_id field is always the taker's order) + taker_exec = next((e for e in taker_executions.data if str(e.order_id) == str(taker_order_id)), None) + maker_exec = next((e for e in maker_executions.data if str(e.maker_order_id) == str(maker_order_id)), None) + + assert taker_exec is not None, f"Taker execution for order {taker_order_id} not found" + assert maker_exec is not None, f"Maker execution for maker_order {maker_order_id} not found" + + logger.info("Found matching executions for both maker and taker") + + # Understanding the execution structure: + # - account_id: ALWAYS the taker's account + # - order_id: ALWAYS the taker's order + # - maker_account_id: ALWAYS the maker's account + # - maker_order_id: ALWAYS the maker's order + # Both taker and maker see the SAME execution record with these fields + + # Validate taker's view of the execution + logger.info("\n📋 Validating TAKER's view of execution:") + assert ( + taker_exec.account_id == taker_tester.account_id + ), f"account_id should be taker's account {taker_tester.account_id}, got {taker_exec.account_id}" + logger.info(f" ✅ account_id = {taker_exec.account_id} (taker's account)") + + assert str(taker_exec.order_id) == str( + taker_order_id + ), f"order_id should be taker's order {taker_order_id}, got {taker_exec.order_id}" + logger.info(f" ✅ order_id = {taker_exec.order_id} (taker's order)") + + assert ( + taker_exec.maker_account_id == maker_tester.account_id + ), f"maker_account_id should be maker's account {maker_tester.account_id}, got {taker_exec.maker_account_id}" + logger.info(f" ✅ maker_account_id = {taker_exec.maker_account_id} (maker's account)") + + assert str(taker_exec.maker_order_id) == str( + maker_order_id + ), f"maker_order_id should be maker's order {maker_order_id}, got {taker_exec.maker_order_id}" + logger.info(f" ✅ maker_order_id = {taker_exec.maker_order_id} (maker's order)") + + # Validate maker's view of the execution (should be identical) + logger.info("\n📋 Validating MAKER's view of execution:") + assert ( + maker_exec.account_id == taker_tester.account_id + ), f"account_id should be taker's account {taker_tester.account_id}, got {maker_exec.account_id}" + logger.info(f" ✅ account_id = {maker_exec.account_id} (taker's account - same as taker's view)") + + assert str(maker_exec.order_id) == str( + taker_order_id + ), f"order_id should be taker's order {taker_order_id}, got {maker_exec.order_id}" + logger.info(f" ✅ order_id = {maker_exec.order_id} (taker's order - same as taker's view)") + + assert ( + maker_exec.maker_account_id == maker_tester.account_id + ), f"maker_account_id should be maker's account {maker_tester.account_id}, got {maker_exec.maker_account_id}" + logger.info(f" ✅ maker_account_id = {maker_exec.maker_account_id} (maker's account)") + + assert str(maker_exec.maker_order_id) == str( + maker_order_id + ), f"maker_order_id should be maker's order {maker_order_id}, got {maker_exec.maker_order_id}" + logger.info(f" ✅ maker_order_id = {maker_exec.maker_order_id} (maker's order)") + + # Validate both executions have matching trade details + logger.info("\n📋 Validating MATCHING trade details:") + assert ( + taker_exec.symbol == maker_exec.symbol == spot_config.symbol + ), f"Symbol mismatch: taker={taker_exec.symbol}, maker={maker_exec.symbol}, expected={spot_config.symbol}" + logger.info(f" ✅ symbol matches: {taker_exec.symbol}") + + assert taker_exec.qty == maker_exec.qty, f"Qty mismatch: taker={taker_exec.qty}, maker={maker_exec.qty}" + logger.info(f" ✅ qty matches: {taker_exec.qty}") + + assert taker_exec.price == maker_exec.price, f"Price mismatch: taker={taker_exec.price}, maker={maker_exec.price}" + logger.info(f" ✅ price matches: {taker_exec.price}") + + # Timestamps should be very close (within 1 second) + timestamp_diff = abs(taker_exec.timestamp - maker_exec.timestamp) + assert timestamp_diff < 1000, f"Timestamp difference too large: {timestamp_diff}ms" # 1000ms = 1 second + logger.info(f" ✅ timestamps match (diff={timestamp_diff}ms)") + + logger.info("\n✅ SPOT EXECUTION MAKER VS TAKER FIELDS VALIDATION COMPLETED") + + +# ============================================================================= +# DEPTH LEVELS VALIDATION +# ============================================================================= + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_depth_response_structure(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test depth response contains all required fields with correct types. + + Validates: + - symbol: str + - type: DepthType enum (SNAPSHOT or UPDATE) + - bids: list[Level] + - asks: list[Level] + - updated_at: int (timestamp) + """ + logger.info("=" * 80) + logger.info("DEPTH RESPONSE STRUCTURE VALIDATION") + logger.info("=" * 80) + + # Fetch depth via REST + depth: Depth = await spot_tester.data.market_depth(spot_config.symbol) + + # Validate type + assert isinstance(depth, Depth), f"Expected Depth type, got {type(depth)}" + + # Validate required fields + assert hasattr(depth, "symbol"), "Depth should have 'symbol' field" + assert isinstance(depth.symbol, str), f"symbol should be str, got {type(depth.symbol)}" + assert depth.symbol == spot_config.symbol, f"Expected symbol {spot_config.symbol}, got {depth.symbol}" + logger.info(f"✅ symbol: {depth.symbol}") + + assert hasattr(depth, "type"), "Depth should have 'type' field" + assert depth.type is not None, "type should not be None" + assert hasattr(depth.type, "value"), f"type should be an enum, got {type(depth.type)}" + logger.info(f"✅ type: {depth.type}") + + assert hasattr(depth, "bids"), "Depth should have 'bids' field" + assert isinstance(depth.bids, list), f"bids should be list, got {type(depth.bids)}" + logger.info(f"✅ bids: {len(depth.bids)} levels") + + assert hasattr(depth, "asks"), "Depth should have 'asks' field" + assert isinstance(depth.asks, list), f"asks should be list, got {type(depth.asks)}" + logger.info(f"✅ asks: {len(depth.asks)} levels") + + assert hasattr(depth, "updated_at"), "Depth should have 'updated_at' field" + assert isinstance(depth.updated_at, int), f"updated_at should be int, got {type(depth.updated_at)}" + assert depth.updated_at > 0, f"updated_at should be positive timestamp, got {depth.updated_at}" + logger.info(f"✅ updated_at: {depth.updated_at}") + + logger.info("✅ DEPTH RESPONSE STRUCTURE VALIDATION COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_depth_level_structure(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test depth level structure contains correct fields. + + Validates each Level: + - px: str (numeric, positive) + - qty: str (numeric, positive) + """ + logger.info("=" * 80) + logger.info("DEPTH LEVEL STRUCTURE VALIDATION") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Place an order to ensure we have at least one level + await spot_config.refresh_order_book(spot_tester.data) + safe_price = spot_config.get_safe_no_match_buy_price() + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(safe_price)).gtc().build() + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + + await asyncio.sleep(0.1) + + # Fetch depth + depth: Depth = await spot_tester.data.market_depth(spot_config.symbol) + + # Validate bid levels + if len(depth.bids) > 0: + for i, level in enumerate(depth.bids[:5]): # Check first 5 + _validate_level_structure(level, f"bid[{i}]") + logger.info(f"✅ Validated {min(5, len(depth.bids))} bid levels") + else: + logger.info("ℹ️ No bid levels to validate") + + # Validate ask levels + if len(depth.asks) > 0: + for i, level in enumerate(depth.asks[:5]): # Check first 5 + _validate_level_structure(level, f"ask[{i}]") + logger.info(f"✅ Validated {min(5, len(depth.asks))} ask levels") + else: + logger.info("ℹ️ No ask levels to validate") + + # Cleanup + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.1) + + logger.info("✅ DEPTH LEVEL STRUCTURE VALIDATION COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_depth_price_sorting(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test depth levels are correctly sorted. + + Validates: + - Bids: sorted descending by price (highest first) + - Asks: sorted ascending by price (lowest first) + """ + logger.info("=" * 80) + logger.info("DEPTH PRICE SORTING VALIDATION") + logger.info("=" * 80) + + # Fetch depth + depth: Depth = await spot_tester.data.market_depth(spot_config.symbol) + + # Validate bid sorting (descending) + if len(depth.bids) >= 2: + bid_prices = [Decimal(level.px) for level in depth.bids] + is_descending = all(bid_prices[i] >= bid_prices[i + 1] for i in range(len(bid_prices) - 1)) + assert is_descending, f"Bids should be sorted descending: {bid_prices[:5]}" + logger.info(f"✅ Bids sorted descending: {[float(p) for p in bid_prices[:3]]}...") + else: + logger.info(f"ℹ️ Only {len(depth.bids)} bid level(s), skipping sort validation") + + # Validate ask sorting (ascending) + if len(depth.asks) >= 2: + ask_prices = [Decimal(level.px) for level in depth.asks] + is_ascending = all(ask_prices[i] <= ask_prices[i + 1] for i in range(len(ask_prices) - 1)) + assert is_ascending, f"Asks should be sorted ascending: {ask_prices[:5]}" + logger.info(f"✅ Asks sorted ascending: {[float(p) for p in ask_prices[:3]]}...") + else: + logger.info(f"ℹ️ Only {len(depth.asks)} ask level(s), skipping sort validation") + + logger.info("✅ DEPTH PRICE SORTING VALIDATION COMPLETED") + + +@pytest.mark.spot +@pytest.mark.validation +@pytest.mark.asyncio +async def test_depth_quantity_aggregation(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test depth correctly aggregates quantities at the same price level. + + This test requires a controlled environment. + When external liquidity exists, we skip. + + Flow: + 1. Place multiple orders at the same price + 2. Verify depth shows aggregated quantity + """ + logger.info("=" * 80) + logger.info("DEPTH QUANTITY AGGREGATION VALIDATION") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Check for external liquidity + await spot_config.refresh_order_book(spot_tester.data) + + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping aggregation test: external liquidity exists. This test requires a controlled environment." + ) + + # Place multiple orders at the same price + safe_price = spot_config.get_safe_no_match_buy_price() + num_orders = 3 + order_ids = [] + + for _ in range(num_orders): + order_params = OrderBuilder.from_config(spot_config).buy().price(str(safe_price)).gtc().build() + order_id = await spot_tester.orders.create_limit(order_params) + assert order_id is not None, "Order creation should return order_id for GTC orders" + await spot_tester.wait.for_order_creation(order_id) + order_ids.append(order_id) + + await asyncio.sleep(0.1) + + # Fetch depth + depth: Depth = await spot_tester.data.market_depth(spot_config.symbol) + + # Find the level at our price + our_level = None + for level in depth.bids: + if abs(Decimal(level.px) - Decimal(str(safe_price))) < Decimal("0.01"): + our_level = level + break + + assert our_level is not None, f"Should find level at price {safe_price}" + + # Verify aggregated quantity + expected_qty = Decimal(spot_config.min_qty) * num_orders + actual_qty = Decimal(our_level.qty) + + assert actual_qty >= expected_qty, f"Aggregated qty should be at least {expected_qty}, got {actual_qty}" + logger.info(f"✅ Quantity correctly aggregated: {actual_qty} (expected >= {expected_qty})") + + # Cleanup + for order_id in order_ids: + try: + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + except (OSError, RuntimeError): + pass + + await asyncio.sleep(0.1) + + logger.info("✅ DEPTH QUANTITY AGGREGATION VALIDATION COMPLETED") + + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + + +def _is_numeric_string(value: str) -> bool: + """Check if a string represents a valid numeric value.""" + try: + Decimal(value) + return True + except (ValueError, TypeError): + return False + + +def _validate_level_structure(level: Level, level_name: str) -> None: + """Validate a single depth level structure.""" + assert isinstance(level, Level), f"{level_name}: Expected Level type, got {type(level)}" + + # px + assert hasattr(level, "px"), f"{level_name}: Level should have 'px'" + assert isinstance(level.px, str), f"{level_name}: px should be str, got {type(level.px)}" + assert _is_numeric_string(level.px), f"{level_name}: px should be numeric: {level.px}" + assert Decimal(level.px) > 0, f"{level_name}: px should be positive: {level.px}" + + # qty + assert hasattr(level, "qty"), f"{level_name}: Level should have 'qty'" + assert isinstance(level.qty, str), f"{level_name}: qty should be str, got {type(level.qty)}" + assert _is_numeric_string(level.qty), f"{level_name}: qty should be numeric: {level.qty}" + assert Decimal(level.qty) > 0, f"{level_name}: qty should be positive: {level.qty}" diff --git a/tests/test_spot/test_self_match_prevention.py b/tests/test_spot/test_self_match_prevention.py new file mode 100644 index 00000000..bd49319b --- /dev/null +++ b/tests/test_spot/test_self_match_prevention.py @@ -0,0 +1,868 @@ +""" +Comprehensive tests for spot self-match prevention. + +The matching engine prevents orders from the same account from matching +against each other. When self-match is detected, the TAKER order is cancelled +and the MAKER order remains on the book. + +Test Categories: +1. Basic Self-Match Prevention (GTC and IOC takers) +2. Price Boundary Cases (exact price, crossing prices) +3. Quantity Scenarios (partial qty, different sizes) +4. Market Maker Scenarios (multiple levels, non-crossing orders) +5. Cross-Account Matching (sanity checks that matching works between accounts) + +NOTE: Self-match prevention tests require a controlled environment where our +maker order is the only liquidity at the test price. When external liquidity +exists at crossing prices, these tests are skipped to avoid false failures. +""" + +import asyncio + +import pytest + +from sdk.open_api.exceptions import ApiException +from sdk.open_api.models.order_status import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.helpers.reya_tester import limit_order_params_to_order, logger +from tests.test_spot.spot_config import SpotTestConfig + + +async def _skip_if_external_liquidity_exists(spot_config: SpotTestConfig, tester: ReyaTester) -> None: + """ + Skip the test if external liquidity exists that could interfere with self-match tests. + + Self-match tests need a controlled environment where our maker order is the only + liquidity at the test price. If external liquidity exists, the taker order might + match against it instead of triggering self-match prevention. + """ + await spot_config.refresh_order_book(tester.data) + + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping self-match test: external liquidity exists in order book. " + "Self-match tests require a controlled environment." + ) + + +# SECTION 1: Basic Self-Match Prevention +# ============================================================================= + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_self_match_gtc_taker_sell_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + GTC buy maker + GTC sell taker (crossing) → taker cancelled. + + Flow: + 1. Place GTC buy order (becomes maker on book) + 2. Place GTC sell order at crossing price from SAME account (taker) + 3. Verify taker is CANCELLED, maker remains OPEN + """ + logger.info("=" * 80) + logger.info("TEST: GTC taker sell cancelled on self-match") + logger.info("=" * 80) + + # Skip if external liquidity exists + await _skip_if_external_liquidity_exists(spot_config, spot_tester) + + await spot_tester.orders.close_all(fail_if_none=False) + + maker_price = spot_config.price(0.97) + _ = maker_price # taker_price - would cross + + # Place maker buy + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + maker_order_id = await spot_tester.orders.create_limit(maker_params) + await spot_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker buy: {maker_order_id} at ${maker_price:.2f}") + + # Place taker sell (same account, crossing) + taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).gtc().build() + taker_order_id = await spot_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.1) + + # Verify + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + + assert taker_order_id not in open_order_ids, "Taker should be CANCELLED" + assert maker_order_id in open_order_ids, "Maker should remain OPEN" + logger.info("✅ Taker cancelled, maker remains open") + + # Cleanup + await spot_tester.client.cancel_order( + order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_self_match_gtc_taker_buy_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + GTC sell maker + GTC buy taker (crossing) → taker cancelled. + + Flow: + 1. Place GTC sell order (becomes maker on book) + 2. Place GTC buy order at crossing price from SAME account (taker) + 3. Verify taker is CANCELLED, maker remains OPEN + """ + logger.info("=" * 80) + logger.info("TEST: GTC taker buy cancelled on self-match") + logger.info("=" * 80) + + # Skip if external liquidity exists + await _skip_if_external_liquidity_exists(spot_config, spot_tester) + + await spot_tester.orders.close_all(fail_if_none=False) + + maker_price = spot_config.price(0.97) + _ = maker_price # taker_price - would cross + + # Place maker sell + maker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).gtc().build() + maker_order_id = await spot_tester.orders.create_limit(maker_params) + await spot_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker sell: {maker_order_id} at ${maker_price:.2f}") + + # Place taker buy (same account, crossing) + taker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + taker_order_id = await spot_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.1) + + # Verify + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + + assert taker_order_id not in open_order_ids, "Taker should be CANCELLED" + assert maker_order_id in open_order_ids, "Maker should remain OPEN" + logger.info("✅ Taker cancelled, maker remains open") + + # Cleanup + await spot_tester.client.cancel_order( + order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_self_match_ioc_taker_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + GTC buy maker + IOC sell taker (crossing) → IOC taker cancelled, no execution. + + Flow: + 1. Place GTC buy order (becomes maker on book) + 2. Send IOC sell order at crossing price from SAME account (taker) + 3. Verify IOC taker is cancelled, no execution occurs + 4. Verify GTC maker remains open on the book + """ + logger.info("=" * 80) + logger.info("TEST: IOC taker cancelled on self-match") + logger.info("=" * 80) + + # Skip if external liquidity exists + await _skip_if_external_liquidity_exists(spot_config, spot_tester) + + await spot_tester.orders.close_all(fail_if_none=False) + spot_tester.ws.last_spot_execution = None + + maker_price = spot_config.price(0.97) + _ = maker_price # taker_price - calculated for reference + + # Place GTC maker buy + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + maker_order_id = await spot_tester.orders.create_limit(maker_params) + await spot_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ GTC maker buy: {maker_order_id}") + + # Send IOC taker sell (same account, crossing) + taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).ioc().build() + + try: + await spot_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.1) + assert spot_tester.ws.last_spot_execution is None, "No execution should occur" + logger.info("✅ No execution - IOC cancelled") + except ApiException as e: + logger.info(f"✅ IOC rejected (self-match prevented): {type(e).__name__}") + + # Verify maker remains + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + assert maker_order_id in open_order_ids, "Maker should remain open" + logger.info("✅ GTC maker remains open") + + # Cleanup + await spot_tester.client.cancel_order( + order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + +# ============================================================================= +# SECTION 2: Price Boundary Cases +# ============================================================================= + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_self_match_exact_price_boundary(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Exact same price from same account triggers self-match prevention. + + When buy and sell are at the exact same price from the same account, + this is considered "in cross" and should trigger self-match prevention. + """ + logger.info("=" * 80) + logger.info("TEST: Exact price boundary triggers self-match") + logger.info("=" * 80) + + # Skip if external liquidity exists + await _skip_if_external_liquidity_exists(spot_config, spot_tester) + + await spot_tester.orders.close_all(fail_if_none=False) + spot_tester.ws.last_spot_execution = None + + exact_price = spot_config.price(0.97) + + # Place maker sell + maker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).gtc().build() + maker_order_id = await spot_tester.orders.create_limit(maker_params) + await spot_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker sell at ${exact_price:.2f}") + + # Place taker buy at EXACT same price + taker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + taker_order_id = await spot_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.1) + + # Verify + assert spot_tester.ws.last_spot_execution is None, "No execution at exact price" + + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + + assert taker_order_id not in open_order_ids, "Taker should be cancelled" + assert maker_order_id in open_order_ids, "Maker should remain" + logger.info("✅ Self-match prevented at exact price boundary") + + # Cleanup + await spot_tester.client.cancel_order( + order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_non_crossing_orders_no_self_match(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Non-crossing orders from same account are NOT self-match. + + Same account places sell at high price, then buy at low price. + Since prices don't cross, both orders should be on the book. + """ + logger.info("=" * 80) + logger.info("TEST: Non-crossing orders are not self-match") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + sell_price = spot_config.price(1.02) # 10% above + buy_price = spot_config.price(0.99) # 10% below + + # Place sell + sell_params = OrderBuilder.from_config(spot_config).sell().at_price(1.02).gtc().build() + sell_order_id = await spot_tester.orders.create_limit(sell_params) + await spot_tester.wait.for_order_creation(sell_order_id) + logger.info(f"✅ Sell at ${sell_price:.2f}") + + # Place buy (non-crossing) + buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.99).gtc().build() + buy_order_id = await spot_tester.orders.create_limit(buy_params) + await spot_tester.wait.for_order_creation(buy_order_id) + logger.info(f"✅ Buy at ${buy_price:.2f}") + + # Verify both are on book + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + + assert sell_order_id in open_order_ids, "Sell should be on book" + assert buy_order_id in open_order_ids, "Buy should be on book" + logger.info("✅ Both non-crossing orders are on the book") + + # Cleanup + for order_id in [sell_order_id, buy_order_id]: + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_non_crossing_ioc_cancelled_no_match(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Non-crossing IOC is cancelled due to no match, NOT self-match. + + Same account places sell at high price, then IOC buy at low price. + The IOC should be cancelled because there's no match available. + """ + logger.info("=" * 80) + logger.info("TEST: Non-crossing IOC cancelled (no match)") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + spot_tester.ws.last_spot_execution = None + + sell_price = spot_config.price(1.04) + _ = spot_config.price(0.96) # buy_price - calculated for reference + + # Place GTC sell + sell_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() + sell_order_id = await spot_tester.orders.create_limit(sell_params) + await spot_tester.wait.for_order_creation(sell_order_id) + logger.info(f"✅ GTC sell at ${sell_price:.2f}") + + # Place IOC buy (non-crossing) + buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).ioc().build() + + try: + await spot_tester.orders.create_limit(buy_params) + await asyncio.sleep(0.1) + assert spot_tester.ws.last_spot_execution is None, "No execution" + logger.info("✅ IOC cancelled - no match available") + except ApiException as e: + logger.info(f"✅ IOC rejected (no match): {type(e).__name__}") + + # Verify GTC sell remains + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + assert sell_order_id in open_order_ids, "GTC sell should remain" + + # Cleanup + await spot_tester.client.cancel_order( + order_id=sell_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + +# ============================================================================= +# SECTION 3: Quantity Scenarios +# ============================================================================= + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_self_match_partial_qty_taker_fully_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Self-match with different quantities: taker is FULLY cancelled. + + When taker would partially match a self-order, the ENTIRE taker + is cancelled (not just the self-matching portion). + """ + logger.info("=" * 80) + logger.info("TEST: Partial qty self-match - taker fully cancelled") + logger.info("=" * 80) + + # Skip if external liquidity exists + await _skip_if_external_liquidity_exists(spot_config, spot_tester) + + await spot_tester.orders.close_all(fail_if_none=False) + spot_tester.ws.last_spot_execution = None + + order_price = spot_config.price(0.97) + maker_qty = "0.02" # Use smaller qty to conserve funds + taker_qty = "0.01" # Minimum order size + + # Place maker sell with large qty + maker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).qty(maker_qty).gtc().build() + maker_order_id = await spot_tester.orders.create_limit(maker_params) + await spot_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker sell: qty={maker_qty}") + + # Place taker buy with smaller qty + taker_params = ( + OrderBuilder.from_config(spot_config) + .buy() + .price(str(round(order_price * 1.01, 2))) + .qty(taker_qty) + .gtc() + .build() + ) + taker_order_id = await spot_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.1) + + # Verify no execution + assert spot_tester.ws.last_spot_execution is None, "No execution" + + # Verify taker cancelled, maker unchanged + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + + assert taker_order_id not in open_order_ids, "Taker should be cancelled" + assert maker_order_id in open_order_ids, "Maker should remain" + + # Verify maker qty unchanged + maker_order = next(o for o in open_orders if o.order_id == maker_order_id) + assert maker_order.qty == maker_qty, f"Maker qty should be {maker_qty}" + logger.info(f"✅ Taker fully cancelled, maker unchanged (qty={maker_order.qty})") + + # Cleanup + await spot_tester.client.cancel_order( + order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_self_match_larger_taker_fully_cancelled(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Self-match with larger taker: taker is FULLY cancelled. + + Even when taker qty > maker qty, the entire taker is cancelled. + """ + logger.info("=" * 80) + logger.info("TEST: Larger taker self-match - fully cancelled") + logger.info("=" * 80) + + # Skip if external liquidity exists + await _skip_if_external_liquidity_exists(spot_config, spot_tester) + + await spot_tester.orders.close_all(fail_if_none=False) + spot_tester.ws.last_spot_execution = None + + order_price = spot_config.price(0.97) + maker_qty = "0.01" # Minimum order size + taker_qty = "0.02" # Slightly larger to test larger taker scenario + + # Place maker sell with smaller qty + maker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).qty(maker_qty).gtc().build() + maker_order_id = await spot_tester.orders.create_limit(maker_params) + await spot_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker sell: qty={maker_qty}") + + # Place taker buy with larger qty + taker_params = ( + OrderBuilder.from_config(spot_config) + .buy() + .price(str(round(order_price * 1.01, 2))) + .qty(taker_qty) + .gtc() + .build() + ) + taker_order_id = await spot_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.1) + + # Verify no execution + assert spot_tester.ws.last_spot_execution is None, "No execution" + + # Verify taker cancelled, maker unchanged + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + + assert taker_order_id not in open_order_ids, "Taker should be cancelled" + assert maker_order_id in open_order_ids, "Maker should remain" + logger.info("✅ Larger taker fully cancelled") + + # Cleanup + await spot_tester.client.cancel_order( + order_id=maker_order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + +# ============================================================================= +# SECTION 4: Market Maker Scenarios +# ============================================================================= + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_market_maker_multiple_non_crossing_levels(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Market maker scenario: same account has multiple price levels on both sides. + + This was a previously problematic scenario - placing multiple sell orders + at different prices, then multiple buy orders at different prices + (all non-crossing). All orders should be added to the book. + """ + logger.info("=" * 80) + logger.info("TEST: Market maker multiple non-crossing levels") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Sells: 102%, 104%, 106% of reference + # Buys: 98%, 96%, 94% of reference + sell_prices = [round(spot_config.oracle_price * (1.02 + i * 0.02), 2) for i in range(3)] + buy_prices = [round(spot_config.oracle_price * (0.98 - i * 0.02), 2) for i in range(3)] + + sell_order_ids = [] + buy_order_ids = [] + + # Place 3 sell orders + logger.info("Placing 3 sell orders at increasing prices...") + for i, price in enumerate(sell_prices): + params = OrderBuilder.from_config(spot_config).sell().price(str(price)).gtc().build() + order_id = await spot_tester.orders.create_limit(params) + await spot_tester.wait.for_order_creation(order_id) + sell_order_ids.append(order_id) + logger.info(f" Sell {i + 1}: ${price:.2f}") + + # Place 3 buy orders + logger.info("Placing 3 buy orders at decreasing prices...") + for i, price in enumerate(buy_prices): + params = OrderBuilder.from_config(spot_config).buy().price(str(price)).gtc().build() + order_id = await spot_tester.orders.create_limit(params) + await spot_tester.wait.for_order_creation(order_id) + buy_order_ids.append(order_id) + logger.info(f" Buy {i + 1}: ${price:.2f}") + + # Verify all 6 orders on book + await asyncio.sleep(0.1) + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + + for order_id in sell_order_ids: + assert order_id in open_order_ids, f"Sell {order_id} should be on book" + for order_id in buy_order_ids: + assert order_id in open_order_ids, f"Buy {order_id} should be on book" + logger.info("✅ All 6 orders are on the book") + + # Cleanup + for order_id in sell_order_ids + buy_order_ids: + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_multiple_self_matches_in_sequence(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Multiple potential self-matches in sequence. + + Place multiple maker orders from same account, then send a taker + that would cross multiple of them. Taker should be cancelled on + first self-match detection. + """ + logger.info("=" * 80) + logger.info("TEST: Multiple self-matches in sequence") + logger.info("=" * 80) + + # Skip if external liquidity exists + await _skip_if_external_liquidity_exists(spot_config, spot_tester) + + await spot_tester.orders.close_all(fail_if_none=False) + spot_tester.ws.last_spot_execution = None + + base_price = spot_config.price(0.97) + maker_order_ids = [] + + # Place 3 maker sells at increasing prices + for i in range(3): + price = round(base_price * (1 + i * 0.01), 2) + params = OrderBuilder.from_config(spot_config).sell().price(str(price)).gtc().build() + order_id = await spot_tester.orders.create_limit(params) + await spot_tester.wait.for_order_creation(order_id) + maker_order_ids.append(order_id) + logger.info(f" Maker sell {i + 1}: ${price:.2f}") + + # Place taker buy that would cross all makers + taker_price = round(base_price * 1.10, 2) # Above all makers + taker_params = ( + OrderBuilder() + .symbol(spot_config.symbol) + .buy() + .price(str(taker_price)) + .qty(str(float(spot_config.min_qty) * 3)) # Enough to match all + .gtc() + .build() + ) + taker_order_id = await spot_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.1) + + # Verify no execution + assert spot_tester.ws.last_spot_execution is None, "No execution" + + # Verify taker cancelled, all makers remain + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + + assert taker_order_id not in open_order_ids, "Taker should be cancelled" + for order_id in maker_order_ids: + assert order_id in open_order_ids, f"Maker {order_id} should remain" + logger.info("✅ Taker cancelled, all makers remain") + + # Cleanup + for order_id in maker_order_ids: + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + + +# ============================================================================= +# SECTION 5: Partial Fill Then Self-Match Scenarios +# ============================================================================= + + +@pytest.mark.spot +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_partial_fill_then_self_match_cancels_remainder( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Taker partially fills against another account, then hits self-match. + + This is a critical edge case: + - Account 1 has BUY 10 @ 100 (best bid) + - Account 2 has BUY 10 @ 99 (second best bid) + - Account 2 sends SELL 15 @ 99 GTC + + Expected behavior: + 1. Account 2's SELL matches Account 1's BUY: 10 lots @ 100 (execution) + 2. Account 2's SELL (5 remaining) would match Account 2's BUY @ 99 (self-match!) + 3. Account 2's SELL is CANCELLED (5 lots remaining, not added to book) + 4. Account 2's BUY @ 99 remains on book (untouched) + + Result: 1 execution, taker cancelled after partial fill, self-order untouched. + """ + # Skip if external liquidity exists - this test requires controlled price levels + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping partial fill self-match test: external liquidity exists. " + "Test requires controlled environment for specific matching behavior." + ) + + logger.info("=" * 80) + logger.info("TEST: Partial fill then self-match cancels remainder") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Prices: Account 1 BUY @ 100, Account 2 BUY @ 99 + account1_buy_price = spot_config.price(0.97) # Best bid (higher) + account2_buy_price = spot_config.price(0.97) # Second best bid (lower) + account2_sell_price = account2_buy_price # Sell at same price as own buy + + fill_qty = spot_config.min_qty # Each order is this qty + taker_qty = str(float(spot_config.min_qty) * 2) # 2x to ensure partial fill + remainder (must be valid qty step) + + # Step 1: Account 1 (maker_tester) places BUY @ 100 (best bid) + account1_buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).qty(fill_qty).gtc().build() + account1_buy_id = await maker_tester.orders.create_limit(account1_buy_params) + await maker_tester.wait.for_order_creation(account1_buy_id) + logger.info(f"✅ Account 1 BUY: {account1_buy_id} @ ${account1_buy_price:.2f}") + + # Step 2: Account 2 (taker_tester) places BUY @ 99 (second best bid) + account2_buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).qty(fill_qty).gtc().build() + account2_buy_id = await taker_tester.orders.create_limit(account2_buy_params) + await taker_tester.wait.for_order_creation(account2_buy_id) + logger.info(f"✅ Account 2 BUY: {account2_buy_id} @ ${account2_buy_price:.2f}") + + # Verify both buys are on book + open_orders_maker = await maker_tester.client.get_open_orders() + open_orders_taker = await taker_tester.client.get_open_orders() + assert any(o.order_id == account1_buy_id for o in open_orders_maker), "Account 1 buy should be on book" + assert any(o.order_id == account2_buy_id for o in open_orders_taker), "Account 2 buy should be on book" + logger.info("✅ Both BUY orders on book") + + # Step 3: Account 2 sends SELL @ 99 with qty = 1.5x (will partially fill then self-match) + account2_sell_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).qty(taker_qty).gtc().build() + logger.info(f"Account 2 sending SELL @ ${account2_sell_price:.2f}, qty={taker_qty}...") + account2_sell_id = await taker_tester.orders.create_limit(account2_sell_params) + + # Wait for execution (strict matching on order_id and all fields) + expected_order = limit_order_params_to_order(account2_sell_params, taker_tester.account_id) + execution = await taker_tester.wait.for_spot_execution(account2_sell_id, expected_order, timeout=5) + logger.info(f"✅ Execution: Account 2 SELL matched Account 1 BUY, qty={execution.qty}") + + # Step 4: Verify Account 1's BUY is filled + await maker_tester.wait.for_order_state(account1_buy_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Account 1's BUY filled") + + # Step 5: Verify Account 2's SELL is CANCELLED (not on book, not partially filled on book) + await asyncio.sleep(0.2) # Allow time for order state to propagate + + open_orders_taker = await taker_tester.client.get_open_orders() + taker_order_ids = [o.order_id for o in open_orders_taker if o.symbol == spot_config.symbol] + + assert ( + account2_sell_id not in taker_order_ids + ), f"Account 2's SELL {account2_sell_id} should be CANCELLED after self-match, not on book" + logger.info("✅ Account 2's SELL cancelled after partial fill (self-match prevention)") + + # Step 6: Verify Account 2's BUY @ 99 is STILL on book (untouched by self-match) + assert ( + account2_buy_id in taker_order_ids + ), f"Account 2's BUY {account2_buy_id} should still be on book (self-match doesn't cancel maker)" + + # Verify the buy order quantity is unchanged + account2_buy_order = next(o for o in open_orders_taker if o.order_id == account2_buy_id) + assert ( + account2_buy_order.qty == fill_qty + ), f"Account 2's BUY qty should be unchanged: expected {fill_qty}, got {account2_buy_order.qty}" + logger.info(f"✅ Account 2's BUY remains on book, qty={account2_buy_order.qty} (untouched)") + + # Cleanup + await taker_tester.client.cancel_order( + order_id=account2_buy_id, symbol=spot_config.symbol, account_id=taker_tester.account_id + ) + await asyncio.sleep(0.05) + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ PARTIAL FILL THEN SELF-MATCH TEST COMPLETED") + + +# ============================================================================= +# SECTION 6: Cross-Account Matching (Sanity Checks) +# ============================================================================= + + +@pytest.mark.spot +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_cross_account_match_works( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Sanity check: matching DOES work between different accounts. + + Confirms that while self-match is prevented, cross-account matching + works correctly. + """ + # Skip if external liquidity exists - taker would match external orders instead of our maker + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping cross-account match test: external liquidity exists. " + "Taker orders would match external liquidity first." + ) + + logger.info("=" * 80) + logger.info("TEST: Cross-account matching works") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + order_price = spot_config.price(1.04) + + # Maker places GTC sell + maker_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker sell: {maker_order_id}") + + # Taker sends IOC buy (different account) + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(round(order_price * 1.01, 2))).ioc().build() + taker_order_id = await taker_tester.orders.create_limit(taker_params) + + # Wait for execution (strict matching on order_id and all fields) + expected_order = limit_order_params_to_order(taker_params, taker_tester.account_id) + execution = await taker_tester.wait.for_spot_execution(taker_order_id, expected_order) + logger.info(f"✅ Execution: {execution.order_id}") + + # Verify maker filled + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Maker filled") + + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + +@pytest.mark.spot +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_non_crossing_orders_can_match_other_accounts( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Non-crossing orders from same account can still match with other accounts. + + Account 1 has non-crossing orders on both sides. + Account 2 places an order that crosses Account 1's order. + The cross-account match should succeed. + """ + # Skip if external liquidity exists - taker would match external orders instead of our maker + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping non-crossing orders test: external liquidity exists. " + "Taker orders would match external liquidity first." + ) + + logger.info("=" * 80) + logger.info("TEST: Non-crossing orders can match other accounts") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + account1_sell_price = spot_config.price(1.04) + account1_buy_price = spot_config.price(0.97) + _ = spot_config.price(0.96) # account2_sell_price - calculated for reference + + # Account 1 places sell at high price + sell_params = OrderBuilder.from_config(spot_config).sell().at_price(1.04).gtc().build() + account1_sell_id = await maker_tester.orders.create_limit(sell_params) + await maker_tester.wait.for_order_creation(account1_sell_id) + logger.info(f"✅ Account 1 sell: ${account1_sell_price:.2f}") + + # Account 1 places buy at low price (non-crossing) + buy_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + account1_buy_id = await maker_tester.orders.create_limit(buy_params) + await maker_tester.wait.for_order_creation(account1_buy_id) + logger.info(f"✅ Account 1 buy: ${account1_buy_price:.2f}") + + # Verify both on book + open_orders = await maker_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + assert account1_sell_id in open_order_ids + assert account1_buy_id in open_order_ids + logger.info("✅ Both Account 1 orders on book") + + # Account 2 places sell that crosses Account 1's buy + taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.96).ioc().build() + taker_order_id = await taker_tester.orders.create_limit(taker_params) + + # Wait for execution (strict matching on order_id and all fields) + expected_order = limit_order_params_to_order(taker_params, taker_tester.account_id) + execution = await taker_tester.wait.for_spot_execution(taker_order_id, expected_order) + logger.info(f"✅ Execution: {execution.order_id}") + + # Verify Account 1's buy filled, sell remains + await maker_tester.wait.for_order_state(account1_buy_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Account 1's buy filled") + + open_orders = await maker_tester.client.get_open_orders() + open_order_ids = [o.order_id for o in open_orders if o.symbol == spot_config.symbol] + assert account1_sell_id in open_order_ids, "Account 1 sell should remain" + logger.info("✅ Account 1's sell remains on book") + + # Cleanup + await maker_tester.client.cancel_order( + order_id=account1_sell_id, symbol=spot_config.symbol, account_id=maker_tester.account_id + ) + await asyncio.sleep(0.05) + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() diff --git a/tests/test_spot/test_state_resilience.py b/tests/test_spot/test_state_resilience.py new file mode 100644 index 00000000..0ae46b01 --- /dev/null +++ b/tests/test_spot/test_state_resilience.py @@ -0,0 +1,383 @@ +""" +Spot State Resilience Tests + +Tests for verifying state consistency and resilience: +- WebSocket reconnection handling +- REST/WebSocket state consistency after activity +- Rapid order operations (stress test) + +These tests validate that the backend's internal state (including Redis-based +order tracking with sequence numbers) correctly propagates to API surfaces. +""" + +import asyncio +import logging +import os + +import pytest + +from sdk.reya_websocket import ReyaSocket +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.asyncio +async def test_spot_order_survives_ws_reconnect(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that open orders are correctly reflected after WebSocket reconnection. + + This tests that the backend correctly maintains order state and provides + accurate snapshots when a client reconnects. This is critical for scenarios + like ME restarts where the backend's Redis state is rebuilt. + + Flow: + 1. Place GTC order + 2. Verify order in open orders via REST and WS + 3. Disconnect WebSocket (real disconnect) + 4. Verify order still exists via REST (backend state intact) + 5. Reconnect WebSocket + 6. Verify WebSocket receives order updates for new activity + 7. Cancel order and verify cleanup + """ + logger.info("=" * 80) + logger.info(f"SPOT ORDER SURVIVES WS RECONNECT TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Clear any existing orders + await spot_tester.orders.close_all(fail_if_none=False) + + # Step 1: Place GTC order + order_price = spot_config.price(0.96) # Far from market + + order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() + + logger.info(f"Placing GTC buy at ${order_price:.2f}...") + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ Order created: {order_id}") + + # Step 2: Verify order exists via REST and WS + open_orders = await spot_tester.client.get_open_orders() + order_ids_before = {o.order_id for o in open_orders if o.symbol == spot_config.symbol} + assert order_id in order_ids_before, f"Order {order_id} should be in REST open orders" + assert order_id in spot_tester.ws.order_changes, f"Order {order_id} should be in WS order changes" + logger.info(f"✅ Order confirmed in both REST and WS: {order_id}") + + # Step 3: Disconnect WebSocket (real disconnect) + logger.info("Disconnecting WebSocket...") + if spot_tester.websocket: + spot_tester.websocket.close() + + # Wait for disconnect to complete + await asyncio.sleep(0.5) + logger.info("✅ WebSocket disconnected") + + # Step 4: Verify order still exists via REST (backend state should be intact) + open_orders_after = await spot_tester.client.get_open_orders() + order_ids_after = {o.order_id for o in open_orders_after if o.symbol == spot_config.symbol} + assert order_id in order_ids_after, ( + f"Order {order_id} should still exist in REST after WS disconnect. " + f"This validates backend state persistence." + ) + logger.info(f"✅ Order still exists in REST after WS disconnect: {order_id}") + + # Step 5: Reconnect WebSocket + logger.info("Reconnecting WebSocket...") + + # Clear old state before reconnect + spot_tester.ws.clear() + + # Create new WebSocket connection + ws_url = os.environ.get("REYA_WS_URL", "wss://ws.reya.xyz/") + spot_tester.websocket = ReyaSocket( + url=ws_url, + on_open=spot_tester.ws.on_open, + on_message=spot_tester.ws.on_message, + ) + spot_tester.websocket.connect() + + # Wait for connection and subscriptions to be established + # WebSocket needs time to connect and subscribe to all channels + await asyncio.sleep(2.0) + logger.info("✅ WebSocket reconnected") + + # Step 6: Verify WebSocket receives updates for new activity + # Place another order to trigger WebSocket activity + _ = spot_config.price(0.96) # order_price_2 - calculated for reference + order_params_2 = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() + + logger.info("Placing second order to verify WS receives updates after reconnect...") + order_id_2 = await spot_tester.orders.create_limit(order_params_2) + await spot_tester.wait.for_order_creation(order_id_2) + + # Verify new order appears in WebSocket state + assert order_id_2 is not None, "Order ID should not be None" + assert ( + order_id_2 in spot_tester.ws.order_changes + ), f"New order {order_id_2} should appear in WS order changes after reconnect" + logger.info(f"✅ WebSocket receiving updates after reconnect: {order_id_2}") + + # Step 7: Cleanup - cancel both orders + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await spot_tester.client.cancel_order( + order_id=order_id_2, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT ORDER SURVIVES WS RECONNECT TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_ws_rest_consistency_after_activity( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test that WebSocket and REST API remain consistent after multiple operations. + + This validates that the backend's internal state (including Redis-based + order tracking with sequence numbers) correctly propagates to both API surfaces. + + Flow: + 1. Place multiple maker orders + 2. Execute partial fills via taker + 3. Cancel remaining orders + 4. Compare final WS state vs REST state + 5. Verify complete consistency + """ + # Skip if external liquidity exists - taker would match external orders instead of our maker + if spot_config.has_any_external_liquidity: + pytest.skip( + "Skipping WS/REST consistency test: external liquidity exists. " + "Taker orders would match external liquidity first." + ) + + logger.info("=" * 80) + logger.info(f"SPOT WS/REST CONSISTENCY 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) + + # Wait for WebSocket subscriptions to be fully established + # This is needed because the previous test may have reconnected the WebSocket + await asyncio.sleep(1.0) + + # Clear WebSocket tracking + maker_tester.ws.order_changes.clear() + + # Step 1: Place multiple maker orders at different prices + maker_prices = [ + spot_config.price(0.97), + spot_config.price(0.97), + spot_config.price(0.97), + ] + + maker_order_ids = [] + for price in maker_prices: + order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + + order_id = await maker_tester.orders.create_limit(order_params) + await maker_tester.wait.for_order_creation(order_id) + maker_order_ids.append(order_id) + logger.info(f"✅ Maker order created at ${price:.2f}: {order_id}") + + # Wait for all orders to be indexed + await asyncio.sleep(0.2) + + # Step 2: Verify all orders appear in both REST and WS + rest_orders = await maker_tester.client.get_open_orders() + rest_order_ids = {o.order_id for o in rest_orders if o.symbol == spot_config.symbol} + ws_order_ids = set(maker_tester.ws.order_changes.keys()) + + for order_id in maker_order_ids: + assert order_id in rest_order_ids, f"Order {order_id} should be in REST" + assert order_id in ws_order_ids, f"Order {order_id} should be in WS" + + logger.info(f"✅ All {len(maker_order_ids)} orders confirmed in both REST and WS") + + # Step 3: Execute a fill on the best price order (highest price = first to match) + _ = maker_prices[-1] # taker_price - below best maker price + + taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).ioc().build() + + logger.info("Taker placing IOC sell to fill one maker order...") + await taker_tester.orders.create_limit(taker_params) + + # Wait for fill to propagate + await asyncio.sleep(0.3) + + # Step 4: Verify one order was filled + rest_orders_after_fill = await maker_tester.client.get_open_orders() + rest_order_ids_after = {o.order_id for o in rest_orders_after_fill if o.symbol == spot_config.symbol} + + filled_count = len(maker_order_ids) - len(rest_order_ids_after) + assert filled_count == 1, f"Expected 1 order filled, got {filled_count}" + logger.info(f"✅ One order filled, {len(rest_order_ids_after)} remaining") + + # Step 5: Verify WS shows filled status for the matched order + filled_order_id = None + for order_id in maker_order_ids: + if order_id not in rest_order_ids_after: + filled_order_id = order_id + break + + assert filled_order_id is not None, "Should have found filled order" + + ws_order = maker_tester.ws.order_changes.get(filled_order_id) + assert ws_order is not None, f"Filled order {filled_order_id} should be in WS" + ws_status = ws_order.status.value if hasattr(ws_order.status, "value") else ws_order.status + assert ws_status == "FILLED", f"WS should show FILLED status, got {ws_status}" + logger.info(f"✅ WS correctly shows FILLED status for {filled_order_id}") + + # Step 6: Cancel remaining orders + remaining_order_ids = [oid for oid in maker_order_ids if oid in rest_order_ids_after] + for order_id in remaining_order_ids: + await maker_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id + ) + + await asyncio.sleep(0.2) + + # Step 7: Final consistency check + final_rest_orders = await maker_tester.client.get_open_orders() + final_rest_ids = {o.order_id for o in final_rest_orders if o.symbol == spot_config.symbol} + + # All our orders should be gone from REST + for order_id in maker_order_ids: + assert order_id not in final_rest_ids, f"Order {order_id} should not be in REST" + + # WS should show CANCELLED for remaining orders + for order_id in remaining_order_ids: + 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 + assert ws_status == "CANCELLED", f"WS should show CANCELLED for {order_id}, got {ws_status}" + + logger.info("✅ Final state: REST and WS are consistent") + + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT WS/REST CONSISTENCY TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.asyncio +async def test_spot_rapid_order_operations(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test rapid order creation and cancellation. + + This stress-tests the backend's event ordering and ensures + sequence-based processing handles high-frequency operations correctly. + + Flow: + 1. Rapidly create 10 orders + 2. Verify all appear in open orders + 3. Rapidly cancel all orders + 4. Verify all cancelled + 5. Verify no orphaned state + """ + logger.info("=" * 80) + logger.info(f"SPOT RAPID ORDER OPERATIONS TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Clear WebSocket tracking + spot_tester.ws.order_changes.clear() + + # Step 1: Rapidly create 10 orders at different prices + num_orders = 10 + base_price = spot_config.price(0.96) + + order_ids = [] + logger.info(f"Creating {num_orders} orders rapidly...") + + for i in range(num_orders): + order_price = round(base_price + (i * 0.5), 2) # 0.5 increments + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(order_price)).gtc().build() + + order_id = await spot_tester.orders.create_limit(order_params) + order_ids.append(order_id) + logger.debug(f" Created order {i + 1}/{num_orders}: {order_id} @ ${order_price}") + + # Small delay to avoid overwhelming the system + await asyncio.sleep(0.02) + + logger.info(f"✅ Created {len(order_ids)} orders") + + # Step 2: Wait for all orders to be confirmed + await asyncio.sleep(0.5) + + # Verify all orders appear in REST + rest_orders = await spot_tester.client.get_open_orders() + rest_order_ids = {o.order_id for o in rest_orders if o.symbol == spot_config.symbol} + + missing_in_rest = [oid for oid in order_ids if oid not in rest_order_ids] + assert len(missing_in_rest) == 0, f"All orders should appear in REST. Missing: {missing_in_rest}" + logger.info(f"✅ All {num_orders} orders confirmed in REST") + + # Verify all orders appear in WebSocket + ws_order_ids = set(spot_tester.ws.order_changes.keys()) + missing_in_ws = [oid for oid in order_ids if oid not in ws_order_ids] + assert len(missing_in_ws) == 0, f"All orders should appear in WS. Missing: {missing_in_ws}" + logger.info(f"✅ All {num_orders} orders confirmed in WS") + + # Step 3: Rapidly cancel all orders + logger.info(f"Cancelling {num_orders} orders rapidly...") + + for i, order_id in enumerate(order_ids): + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + logger.debug(f" Cancelled order {i + 1}/{num_orders}: {order_id}") + + # Small delay to avoid overwhelming the system + await asyncio.sleep(0.02) + + logger.info(f"✅ Sent cancel requests for {len(order_ids)} orders") + + # Step 4: Wait for all cancellations to propagate + await asyncio.sleep(0.5) + + # Verify all orders are cancelled in REST + final_rest_orders = await spot_tester.client.get_open_orders() + final_rest_ids = {o.order_id for o in final_rest_orders if o.symbol == spot_config.symbol} + + still_open_in_rest = [oid for oid in order_ids if oid in final_rest_ids] + assert len(still_open_in_rest) == 0, f"All orders should be cancelled in REST. Still open: {still_open_in_rest}" + logger.info(f"✅ All {num_orders} orders cancelled in REST") + + # Verify all orders show CANCELLED in WebSocket + not_cancelled_in_ws = [] + for order_id in order_ids: + assert order_id is not None, "Order ID should not be None" + ws_order = spot_tester.ws.order_changes.get(order_id) + if ws_order: + ws_status = ws_order.status.value if hasattr(ws_order.status, "value") else ws_order.status + if ws_status != "CANCELLED": + not_cancelled_in_ws.append((order_id, ws_status)) + + assert ( + len(not_cancelled_in_ws) == 0 + ), f"All orders should show CANCELLED in WS. Not cancelled: {not_cancelled_in_ws}" + logger.info(f"✅ All {num_orders} orders show CANCELLED in WS") + + # Step 5: Final verification - no orphaned state + await spot_tester.check.no_open_orders() + + logger.info("✅ No orphaned state detected") + logger.info("✅ SPOT RAPID ORDER OPERATIONS TEST COMPLETED") diff --git a/tests/test_spot/test_wallet_spot_executions.py b/tests/test_spot/test_wallet_spot_executions.py new file mode 100644 index 00000000..39eb1f63 --- /dev/null +++ b/tests/test_spot/test_wallet_spot_executions.py @@ -0,0 +1,259 @@ +""" +Wallet Spot Executions REST API Tests + +Tests for the GET /v2/wallet/:address/spotExecutions endpoint: +- Fetching wallet-specific spot execution history +- Empty executions list handling +- Pagination support +""" + +import asyncio +import logging + +import pytest + +from sdk.open_api.models import OrderStatus +from sdk.open_api.models.spot_execution import SpotExecution +from sdk.open_api.models.spot_execution_list import SpotExecutionList +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import OrderBuilder +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + + +@pytest.mark.spot +@pytest.mark.rest_api +@pytest.mark.asyncio +async def test_rest_get_wallet_spot_executions_structure( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test wallet spot executions REST endpoint returns correct structure. + + Supports both empty and non-empty order books: + - If external liquidity exists, taker trades against it + - If no external liquidity, maker provides liquidity first + + Flow: + 1. Execute a trade to ensure execution history exists + 2. Fetch wallet spot executions via REST + 3. Verify response structure and data types + """ + logger.info("=" * 80) + logger.info("WALLET SPOT EXECUTIONS STRUCTURE TEST") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Refresh order book state + await spot_config.refresh_order_book(taker_tester.data) + + # Execute a trade to create execution history + usable_bid = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + maker_order_id = None + + if usable_bid is not None: + logger.info(f"Using external bid liquidity at ${usable_bid}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(usable_bid)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) + elif usable_ask is not None: + logger.info(f"Using external ask liquidity at ${usable_ask}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(usable_ask)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) + else: + logger.info("No external liquidity - creating maker order") + 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(0.3) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + + logger.info("✅ Trade executed - execution history created") + + # Fetch wallet spot executions via REST + wallet_address = taker_tester.owner_wallet_address + assert wallet_address is not None, "Wallet address required" + + executions: SpotExecutionList = await taker_tester.client.wallet.get_wallet_spot_executions(address=wallet_address) + + # Verify response structure + assert executions is not None, "Response should not be None" + assert isinstance(executions, SpotExecutionList), f"Expected SpotExecutionList, got {type(executions)}" + assert hasattr(executions, "data"), "Response should have 'data' attribute" + assert isinstance(executions.data, list), "data should be a list" + + logger.info(f"Wallet spot executions returned: {len(executions.data)}") + + # Verify we have at least one execution + assert len(executions.data) > 0, "Should have at least one execution after trade" + + # Verify execution data structure + latest: SpotExecution = executions.data[0] + assert isinstance(latest, SpotExecution), f"Expected SpotExecution, got {type(latest)}" + + logger.info("✅ Response structure validated") + logger.info("✅ WALLET SPOT EXECUTIONS STRUCTURE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.rest_api +@pytest.mark.asyncio +async def test_rest_get_wallet_spot_executions_pagination( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test wallet spot executions REST endpoint pagination. + + This test requires a controlled environment to verify multiple trades. + When external liquidity exists, we skip to avoid unpredictable matching. + + Flow: + 1. Check for external liquidity - skip if present + 2. Execute multiple trades + 3. Fetch executions with pagination parameters + 4. Verify pagination works correctly + """ + logger.info("=" * 80) + logger.info("WALLET SPOT EXECUTIONS PAGINATION TEST") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Refresh order book state + await spot_config.refresh_order_book(maker_tester.data) + + # Skip if external liquidity exists + if spot_config.has_any_external_liquidity: + pytest.skip("Skipping pagination test: external liquidity exists. This test requires a controlled environment.") + + # Execute multiple trades to have pagination data + num_trades = 3 + for i in range(num_trades): + maker_price = round(spot_config.oracle_price * (0.96 + i * 0.01), 2) + + maker_params = OrderBuilder.from_config(spot_config).buy().price(str(maker_price)).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().price(str(maker_price)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.1) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info(f"✅ Trade {i + 1}/{num_trades} executed") + + await asyncio.sleep(0.5) + + # Fetch all executions + wallet_address = taker_tester.owner_wallet_address + assert wallet_address is not None + + all_executions: SpotExecutionList = await taker_tester.client.wallet.get_wallet_spot_executions( + address=wallet_address + ) + + assert len(all_executions.data) >= num_trades, f"Expected at least {num_trades} executions" + logger.info(f"Total executions: {len(all_executions.data)}") + + # Test with time-based pagination (start_time/end_time) + if len(all_executions.data) >= 2: + # Use the timestamp of the second execution as end_time to get only older executions + second_execution = all_executions.data[1] + end_time = second_execution.timestamp + + filtered_executions: SpotExecutionList = await taker_tester.client.wallet.get_wallet_spot_executions( + address=wallet_address, end_time=end_time + ) + + # Should return executions up to and including the end_time + assert len(filtered_executions.data) >= 1, "Should have at least one execution before end_time" + logger.info(f"Filtered executions (end_time={end_time}): {len(filtered_executions.data)}") + + logger.info("✅ Pagination parameters work correctly") + logger.info("✅ WALLET SPOT EXECUTIONS PAGINATION TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.rest_api +@pytest.mark.asyncio +async def test_rest_get_wallet_spot_executions_filters_by_wallet( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test wallet spot executions are filtered by wallet address. + + Flow: + 1. Execute a trade between maker and taker + 2. Fetch executions for taker wallet + 3. Verify executions belong to taker's account + """ + logger.info("=" * 80) + logger.info("WALLET SPOT EXECUTIONS FILTER BY WALLET TEST") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Refresh order book state + await spot_config.refresh_order_book(taker_tester.data) + + # Execute a trade + usable_bid = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + maker_order_id = None + + if usable_bid is not None: + logger.info(f"Using external bid liquidity at ${usable_bid}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(usable_bid)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) + elif usable_ask is not None: + logger.info(f"Using external ask liquidity at ${usable_ask}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(usable_ask)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) + else: + logger.info("No external liquidity - creating maker order") + 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(0.3) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + + logger.info("✅ Trade executed") + + # Fetch executions for taker wallet + taker_wallet = taker_tester.owner_wallet_address + assert taker_wallet is not None + + taker_executions: SpotExecutionList = await taker_tester.client.wallet.get_wallet_spot_executions( + address=taker_wallet + ) + + assert len(taker_executions.data) > 0, "Taker should have executions" + + # Verify executions belong to taker's account + taker_account_id = taker_tester.account_id + for execution in taker_executions.data[:5]: # Check first 5 + # Execution should involve taker's account (as account_id or maker_account_id) + is_taker_execution = execution.account_id == taker_account_id or execution.maker_account_id == taker_account_id + assert is_taker_execution, ( + f"Execution should involve taker account {taker_account_id}, " + f"got account_id={execution.account_id}, maker_account_id={execution.maker_account_id}" + ) + + logger.info(f"✅ All executions involve taker account {taker_account_id}") + logger.info("✅ WALLET SPOT EXECUTIONS FILTER BY WALLET TEST COMPLETED") diff --git a/tests/test_spot/test_websocket_events.py b/tests/test_spot/test_websocket_events.py new file mode 100644 index 00000000..7841961b --- /dev/null +++ b/tests/test_spot/test_websocket_events.py @@ -0,0 +1,416 @@ +""" +Spot WebSocket Event Verification Tests + +Tests for verifying WebSocket events during spot trading: +- Order changes on create +- Order changes on fill +- Order changes on cancel +- Spot executions +- Balance updates + +These tests verify both that events are received AND that the event +content matches expectations using centralized assertion helpers. +""" + +import asyncio +import logging + +import pytest + +from sdk.open_api.models import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders.order_builder import OrderBuilder +from tests.helpers.reya_tester import limit_order_params_to_order +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.asyncio +async def test_spot_ws_order_changes_on_create(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test WebSocket orderChanges event received on order creation. + + Flow: + 1. Clear order change tracking + 2. Place GTC order + 3. Verify orderChanges event received via WebSocket + 4. Verify event contains correct order data (symbol, side, qty) + """ + logger.info("=" * 80) + logger.info(f"SPOT WS ORDER CHANGES ON CREATE TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Clear WebSocket tracking + spot_tester.ws.order_changes.clear() + + # Place GTC order + order_price = spot_config.price(0.96) + + order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() + + logger.info(f"Placing GTC buy: {spot_config.min_qty} @ ${order_price:.2f}") + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ Order created: {order_id}") + + # Verify order change event using ReyaTester method (wait.for_order_creation already waits for WS) + spot_tester.check.ws_order_change_received( + order_id=order_id, + expected_symbol=spot_config.symbol, + expected_side="B", + expected_qty=spot_config.min_qty, + ) + + # Cleanup + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + await asyncio.sleep(0.05) + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT WS ORDER CHANGES ON CREATE TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_ws_order_changes_on_fill( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test WebSocket orderChanges event received on order fill. + + Works with external liquidity by using IOC orders that match against + external bids, or falls back to maker-taker matching if no external liquidity. + + Flow: + 1. Place IOC sell order to match external bids (or maker order) + 2. Verify orderChanges event shows FILLED status + """ + logger.info("=" * 80) + logger.info(f"SPOT WS ORDER CHANGES ON FILL TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(taker_tester.data) + + # Clear WebSocket tracking for taker + taker_tester.ws.order_changes.clear() + + # Determine how to execute a fill based on liquidity + if spot_config.has_usable_ask_liquidity: + # External asks exist - taker buys from external + ask_price = spot_config.best_ask_price + assert ask_price is not None + trade_price = float(ask_price) + logger.info(f"Using external ask liquidity at ${trade_price:.2f}") + + # Place GTC buy order that will match external asks + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(ask_price)).gtc().build() + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker placing GTC buy: {spot_config.min_qty} @ ${trade_price:.2f}") + + # Wait for fill + await taker_tester.wait.for_order_state(taker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Taker order filled by external liquidity") + + # Verify order change event + taker_tester.check.ws_order_change_received( + order_id=taker_order_id, + expected_symbol=spot_config.symbol, + expected_status=OrderStatus.FILLED, + ) + elif spot_config.has_usable_bid_liquidity: + # External bids exist - taker sells to external + bid_price = spot_config.best_bid_price + assert bid_price is not None + trade_price = float(bid_price) + logger.info(f"Using external bid liquidity at ${trade_price:.2f}") + + # Place GTC sell order that will match external bids + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(bid_price)).gtc().build() + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"Taker placing GTC sell: {spot_config.min_qty} @ ${trade_price:.2f}") + + # Wait for fill + await taker_tester.wait.for_order_state(taker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Taker order filled by external liquidity") + + # Verify order change event + taker_tester.check.ws_order_change_received( + order_id=taker_order_id, + expected_symbol=spot_config.symbol, + expected_status=OrderStatus.FILLED, + ) + else: + # No external liquidity - use maker-taker matching + maker_tester.ws.order_changes.clear() + maker_price = spot_config.price(0.97) + + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + logger.info(f"Maker placing GTC buy: {spot_config.min_qty} @ ${maker_price:.2f}") + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id}") + + # Taker fills the order + taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).ioc().build() + logger.info("Taker placing IOC sell to fill maker order...") + await taker_tester.orders.create_limit(taker_params) + + # Wait for fill + await asyncio.sleep(0.05) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Maker order filled") + + # Verify order change event + maker_tester.check.ws_order_change_received( + order_id=maker_order_id, + expected_symbol=spot_config.symbol, + expected_status=OrderStatus.FILLED, + ) + + # Verify no open orders + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT WS ORDER CHANGES ON FILL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.asyncio +async def test_spot_ws_order_changes_on_cancel(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test WebSocket orderChanges event received on order cancel. + + Flow: + 1. Place GTC order + 2. Cancel the order + 3. Verify orderChanges event shows CANCELLED status + """ + logger.info("=" * 80) + logger.info(f"SPOT WS ORDER CHANGES ON CANCEL TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Place GTC order + order_price = spot_config.price(0.96) + + order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() + + logger.info(f"Placing GTC buy: {spot_config.min_qty} @ ${order_price:.2f}") + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ Order created: {order_id}") + + # Clear WebSocket tracking before cancel to capture the cancel event + spot_tester.ws.order_changes.clear() + + # Cancel the order + logger.info("Cancelling order...") + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + + # Wait for cancellation + await spot_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED, timeout=5) + logger.info("✅ Order cancelled") + + # Verify order change event using ReyaTester method + spot_tester.check.ws_order_change_received( + order_id=order_id, + expected_symbol=spot_config.symbol, + expected_status=OrderStatus.CANCELLED, + ) + + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT WS ORDER CHANGES ON CANCEL TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_ws_spot_executions(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): + """ + Test WebSocket spotExecutions event received on trade. + + Supports both empty and non-empty order books: + - If external bid liquidity exists, taker sells into it + - If no external liquidity, maker provides bid liquidity first + + Flow: + 1. Check for external liquidity + 2. If needed, maker places GTC order + 3. Taker fills with IOC order + 4. Verify spotExecutions event received with correct details + """ + logger.info("=" * 80) + logger.info(f"SPOT WS SPOT EXECUTIONS 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 WebSocket tracking + taker_tester.ws.last_spot_execution = None + + # Check for external liquidity + await spot_config.refresh_order_book(maker_tester.data) + + maker_order_id = None + usable_bid_price = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask_price = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid_price is not None: + # External bid liquidity exists - taker sells into it + fill_price = float(usable_bid_price) + logger.info(f"Using external bid liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() + logger.info(f"Taker placing IOC sell: {spot_config.min_qty} @ ${fill_price:.2f}") + expected_side = "A" # Taker was selling + elif usable_ask_price is not None: + # External ask liquidity exists - taker buys from it + fill_price = float(usable_ask_price) + logger.info(f"Using external ask liquidity at ${fill_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(fill_price)).ioc().build() + logger.info(f"Taker placing IOC buy: {spot_config.min_qty} @ ${fill_price:.2f}") + expected_side = "B" # Taker was buying + else: + # No external liquidity - provide our own + fill_price = spot_config.price(0.97) + + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + + logger.info(f"Maker placing GTC buy: {spot_config.min_qty} @ ${fill_price:.2f}") + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id}") + + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(fill_price)).ioc().build() + logger.info(f"Taker placing IOC sell: {spot_config.min_qty} @ ${fill_price:.2f}") + expected_side = "A" # Taker was selling + + taker_order_id = await taker_tester.orders.create_limit(taker_params) + + # Wait for spot execution event via WebSocket (strict matching on order_id and all fields) + expected_order = limit_order_params_to_order(taker_params, taker_tester.account_id) + execution = await taker_tester.wait.for_spot_execution(taker_order_id, expected_order, timeout=5) + + # Verify spot execution details + assert execution is not None, "No spot execution event received via WebSocket" + assert execution.symbol == spot_config.symbol + assert execution.side.value == expected_side + assert execution.qty == spot_config.min_qty + # Price may differ slightly due to order book changes, just verify it's within circuit breaker range + exec_price = float(execution.price) + assert ( + spot_config.circuit_breaker_floor <= exec_price <= spot_config.circuit_breaker_ceiling + ), f"Fill price ${exec_price} should be within circuit breaker range" + logger.info(f"✅ Spot execution received: {execution.order_id}") + + # Verify no open orders + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT WS SPOT EXECUTIONS TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_ws_balance_updates(spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester): + """ + Test WebSocket accountBalances event received on trade. + + This test verifies that balance update events are received via WebSocket + after a trade executes. Works with external liquidity by using IOC orders + that match against external bids. + + Flow: + 1. Record initial balance update count + 2. Execute a trade (IOC sell against external bids or maker order) + 3. Verify balance update events received via WebSocket + """ + logger.info("=" * 80) + logger.info(f"SPOT WS BALANCE UPDATES TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Check current order book state + await spot_config.refresh_order_book(taker_tester.data) + + # Record initial balance update count for taker + taker_initial_count = taker_tester.ws.get_balance_update_count() + logger.info(f"Initial taker balance update count: {taker_initial_count}") + + # Determine trade price based on liquidity + if spot_config.has_usable_bid_liquidity: + # External bids exist - taker sells to external + bid_price = spot_config.best_bid_price + assert bid_price is not None + trade_price = float(bid_price) + logger.info(f"Using external bid liquidity at ${trade_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(bid_price)).ioc().build() + expected_asset = "RUSD" # Taker sells ETH, receives RUSD + + # Taker executes IOC sell + logger.info(f"Taker placing IOC sell: {spot_config.min_qty} @ ${trade_price:.2f}") + await taker_tester.orders.create_limit(taker_params) + elif spot_config.has_usable_ask_liquidity: + # External asks exist - taker buys from external + ask_price = spot_config.best_ask_price + assert ask_price is not None + trade_price = float(ask_price) + logger.info(f"Using external ask liquidity at ${trade_price:.2f}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(ask_price)).ioc().build() + expected_asset = "ETH" # Taker buys ETH, spends RUSD + + # Taker executes IOC buy + logger.info(f"Taker placing IOC buy: {spot_config.min_qty} @ ${trade_price:.2f}") + await taker_tester.orders.create_limit(taker_params) + else: + # No external liquidity - maker places order, taker fills + trade_price = spot_config.price(0.97) + maker_params = OrderBuilder.from_config(spot_config).buy().at_price(0.97).gtc().build() + logger.info(f"Maker placing GTC buy: {spot_config.min_qty} @ ${trade_price:.2f}") + maker_order_id = await maker_tester.orders.create_limit(maker_params) + await maker_tester.wait.for_order_creation(maker_order_id) + logger.info(f"✅ Maker order created: {maker_order_id}") + taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).ioc().build() + expected_asset = "RUSD" # Taker sells ETH, receives RUSD + + # Taker executes IOC sell + logger.info(f"Taker placing IOC sell: {spot_config.min_qty} @ ${trade_price:.2f}") + await taker_tester.orders.create_limit(taker_params) + + # Wait for balance updates via WebSocket + await taker_tester.wait.for_balance_updates(taker_initial_count, min_updates=1, timeout=5.0) + + # Verify taker received balance updates + taker_tester.check.ws_balance_updates_received( + initial_update_count=taker_initial_count, + min_updates=1, + expected_assets=[expected_asset], + ) + + # Verify no open orders + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT WS BALANCE UPDATES TEST COMPLETED") diff --git a/tests/test_spot/test_websocket_snapshots.py b/tests/test_spot/test_websocket_snapshots.py new file mode 100644 index 00000000..79dab953 --- /dev/null +++ b/tests/test_spot/test_websocket_snapshots.py @@ -0,0 +1,622 @@ +""" +WebSocket Snapshot Tests for Spot Trading + +Tests for verifying WebSocket initial snapshots: +- Depth channel initial snapshot +- Order changes initial snapshot +- Balances initial snapshot +- Incremental updates after snapshot + +These tests verify that when subscribing to a WebSocket channel, +the initial snapshot contains the correct state before incremental updates. +""" + +import asyncio +import logging + +import pytest + +from sdk.async_api.depth import Depth +from sdk.async_api.level import Level +from sdk.open_api.models import OrderStatus +from tests.helpers import ReyaTester +from tests.helpers.builders import OrderBuilder +from tests.test_spot.spot_config import SpotTestConfig + +logger = logging.getLogger("reya.integration_tests") + +# DEPTH CHANNEL SNAPSHOT TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.asyncio +async def test_spot_depth_ws_initial_snapshot(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that subscribing to /depth channel returns correct initial snapshot. + + Uses safe no-match prices ($10 buy) to ensure orders don't match external liquidity + while still verifying they appear in the depth snapshot. + + Flow: + 1. Place multiple GTC buy orders at safe no-match prices + 2. Subscribe to /depth channel + 3. Verify initial snapshot message contains our orders + 4. Verify bid ordering is correct in snapshot (descending by price) + """ + logger.info("=" * 80) + logger.info(f"SPOT DEPTH WS INITIAL SNAPSHOT TEST: {spot_config.symbol}") + logger.info("=" * 80) + + # Clear any existing orders + await spot_tester.orders.close_all(fail_if_none=False) + + # Step 1: Place multiple GTC orders at safe no-match prices + # Use prices around $10 (far below market) to avoid matching external liquidity + base_safe_price = float(spot_config.get_safe_no_match_buy_price()) + prices = [ + base_safe_price, + base_safe_price + 1, # $11 + base_safe_price + 2, # $12 + ] + + order_ids = [] + for price in prices: + order_params = OrderBuilder.from_config(spot_config).buy().price(str(price)).gtc().build() + + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + order_ids.append(order_id) + logger.info(f"✅ Order created at ${price:.2f}: {order_id}") + + # Wait for orders to be fully indexed + await asyncio.sleep(0.2) + + # Step 2: Clear depth state and subscribe to depth channel + spot_tester.ws.last_depth.clear() + spot_tester.ws.subscribe_to_market_depth(spot_config.symbol) + + # Wait for snapshot to arrive + await asyncio.sleep(0.5) + + # Step 3: Verify initial snapshot contains our orders + depth_snapshot = spot_tester.ws.last_depth.get(spot_config.symbol) + assert depth_snapshot is not None, "Should have received depth snapshot via WebSocket" + assert isinstance(depth_snapshot, Depth), f"Expected Depth type, got {type(depth_snapshot)}" + + bids = depth_snapshot.bids + logger.info(f"Depth snapshot received: {len(bids)} bids") + + # Verify our orders are in the snapshot (using typed Level.px attribute) + found_prices = set() + for bid in bids: + assert isinstance(bid, Level), f"Expected Level type, got {type(bid)}" + bid_price = float(bid.px) + for expected_price in prices: + if abs(bid_price - expected_price) < 1.0: # Allow small tolerance + found_prices.add(expected_price) + logger.info(f" ✅ Found order at ${bid_price:.2f}") + + assert len(found_prices) == len( + prices + ), f"Expected to find all {len(prices)} orders in snapshot, found {len(found_prices)}. Bids: {bids}" + logger.info(f"✅ All {len(prices)} orders found in initial snapshot") + + # Step 4: Verify bid ordering (descending by price) + bid_prices = [float(b.px) for b in bids] + if len(bid_prices) >= 2: + is_descending = all(bid_prices[i] >= bid_prices[i + 1] for i in range(len(bid_prices) - 1)) + assert is_descending, f"Bids should be in descending order: {bid_prices}" + logger.info("✅ Bids are correctly ordered (descending by price)") + + # Cleanup + for order_id in order_ids: + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT DEPTH WS INITIAL SNAPSHOT TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_depth_ws_snapshot_with_asks( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test depth snapshot includes both bids and asks. + + Uses safe no-match prices ($10 buy, $10M sell) to ensure orders don't match + external liquidity while still verifying they appear in the depth snapshot. + + Flow: + 1. Maker places buy order at safe no-match price ($10) + 2. Taker places sell order at safe no-match price ($10M) + 3. Subscribe to depth + 4. Verify snapshot contains both bid and ask + """ + logger.info("=" * 80) + logger.info(f"SPOT DEPTH WS SNAPSHOT WITH ASKS 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) + + # Place bid at safe no-match price (maker buys with RUSD) + bid_price = spot_config.get_safe_no_match_buy_price() + bid_params = OrderBuilder.from_config(spot_config).buy().price(str(bid_price)).gtc().build() + + bid_order_id = await maker_tester.orders.create_limit(bid_params) + await maker_tester.wait.for_order_creation(bid_order_id) + logger.info(f"✅ Bid order created at ${bid_price:.2f}") + + # Place ask at safe no-match price (taker sells ETH) + ask_price = spot_config.get_safe_no_match_sell_price() + ask_params = OrderBuilder.from_config(spot_config).sell().price(str(ask_price)).gtc().build() + + ask_order_id = await taker_tester.orders.create_limit(ask_params) + await taker_tester.wait.for_order_creation(ask_order_id) + logger.info(f"✅ Ask order created at ${ask_price:.2f}") + + # Wait for orders to be indexed + await asyncio.sleep(0.2) + + # Subscribe to depth + maker_tester.ws.last_depth.clear() + maker_tester.ws.subscribe_to_market_depth(spot_config.symbol) + + await asyncio.sleep(0.5) + + # Verify snapshot + depth_snapshot = maker_tester.ws.last_depth.get(spot_config.symbol) + assert depth_snapshot is not None, "Should have received depth snapshot" + assert isinstance(depth_snapshot, Depth), f"Expected Depth type, got {type(depth_snapshot)}" + + bids = depth_snapshot.bids + asks = depth_snapshot.asks + + logger.info(f"Snapshot: {len(bids)} bids, {len(asks)} asks") + + # Find our orders (using typed Level.px attribute) + bid_found = any(abs(float(b.px) - float(bid_price)) < 1.0 for b in bids) + ask_found = any(abs(float(a.px) - float(ask_price)) < 1.0 for a in asks) + + assert bid_found, f"Bid at ${bid_price:.2f} not found in snapshot" + assert ask_found, f"Ask at ${ask_price:.2f} not found in snapshot" + + logger.info("✅ Both bid and ask found in snapshot") + + # Verify ask ordering (ascending by price) + if len(asks) >= 2: + ask_prices = [float(a.px) for a in asks] + is_ascending = all(ask_prices[i] <= ask_prices[i + 1] for i in range(len(ask_prices) - 1)) + assert is_ascending, f"Asks should be in ascending order: {ask_prices}" + logger.info("✅ Asks are correctly ordered (ascending by price)") + + # Cleanup + await maker_tester.client.cancel_order( + order_id=bid_order_id, symbol=spot_config.symbol, account_id=maker_tester.account_id + ) + await taker_tester.client.cancel_order( + order_id=ask_order_id, symbol=spot_config.symbol, account_id=taker_tester.account_id + ) + + await asyncio.sleep(0.1) + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT DEPTH WS SNAPSHOT WITH ASKS TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.asyncio +async def test_spot_depth_ws_incremental_after_snapshot(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test incremental updates are received after initial snapshot. + + Uses safe no-match price ($10 buy) to ensure orders don't match external liquidity + while still verifying incremental updates work correctly. + + Flow: + 1. Subscribe to /depth channel (receive initial snapshot) + 2. Place a new order at safe no-match price + 3. Verify incremental update is received with new order + 4. Cancel the order + 5. Verify incremental update removes the order + """ + logger.info("=" * 80) + logger.info(f"SPOT DEPTH WS INCREMENTAL AFTER SNAPSHOT TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Step 1: Subscribe to depth and get initial snapshot + spot_tester.ws.last_depth.clear() + spot_tester.ws.subscribe_to_market_depth(spot_config.symbol) + + await asyncio.sleep(0.3) + + initial_snapshot = spot_tester.ws.last_depth.get(spot_config.symbol) + initial_bid_count = len(initial_snapshot.bids) if initial_snapshot else 0 + logger.info(f"Initial snapshot: {initial_bid_count} bids") + + # Step 2: Place a new order at safe no-match price + order_price = float(spot_config.get_safe_no_match_buy_price()) + + order_params = OrderBuilder.from_config(spot_config).buy().price(str(order_price)).gtc().build() + + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + logger.info(f"✅ New order created at ${order_price:.2f}") + + # Wait for incremental update + await asyncio.sleep(0.3) + + # Step 3: Verify incremental update contains new order + updated_depth = spot_tester.ws.last_depth.get(spot_config.symbol) + assert updated_depth is not None, "Should have received depth update" + assert isinstance(updated_depth, Depth), f"Expected Depth type, got {type(updated_depth)}" + + bids = updated_depth.bids + order_found = any(abs(float(b.px) - order_price) < 0.01 for b in bids) + assert order_found, f"New order at ${order_price:.2f} should appear in depth after incremental update" + logger.info("✅ New order appears in depth after incremental update") + + # Step 4: Cancel the order + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + + await spot_tester.wait.for_order_state(order_id, OrderStatus.CANCELLED) + logger.info("Order cancelled") + + # Wait for incremental update + await asyncio.sleep(0.3) + + # Step 5: Verify order removed from depth + final_depth = spot_tester.ws.last_depth.get(spot_config.symbol) + final_bids = final_depth.bids if final_depth else [] + + order_still_present = any(abs(float(b.px) - order_price) < 0.01 for b in final_bids) + assert not order_still_present, f"Cancelled order at ${order_price:.2f} should be removed from depth" + logger.info("✅ Cancelled order removed from depth after incremental update") + + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT DEPTH WS INCREMENTAL AFTER SNAPSHOT TEST COMPLETED") + + +# ============================================================================ +# ORDER CHANGES CHANNEL SNAPSHOT TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.asyncio +async def test_spot_order_changes_ws_initial_snapshot(spot_config: SpotTestConfig, spot_tester: ReyaTester): + """ + Test that subscribing to orderChanges returns correct initial snapshot. + + Note: The orderChanges channel may not send a snapshot of existing orders + on subscription - it typically only sends updates for new order events. + This test verifies the behavior. + + Flow: + 1. Place multiple GTC orders (create open orders) + 2. Record the orders via REST + 3. Verify orderChanges were received for each order creation + """ + logger.info("=" * 80) + logger.info(f"SPOT ORDER CHANGES WS INITIAL SNAPSHOT TEST: {spot_config.symbol}") + logger.info("=" * 80) + + await spot_tester.orders.close_all(fail_if_none=False) + + # Clear order changes tracking + spot_tester.ws.order_changes.clear() + + # Place multiple orders + prices = [ + spot_config.price(0.96), + spot_config.price(0.96), + ] + + order_ids = [] + for _ in prices: + order_params = OrderBuilder.from_config(spot_config).buy().at_price(0.96).gtc().build() + + order_id = await spot_tester.orders.create_limit(order_params) + await spot_tester.wait.for_order_creation(order_id) + order_ids.append(order_id) + logger.info(f"✅ Order created: {order_id}") + + # Wait for WebSocket events + await asyncio.sleep(0.2) + + # Verify order changes were received for each order + for order_id in order_ids: + assert order_id is not None, "Order ID should not be None" + assert order_id in spot_tester.ws.order_changes, f"Order {order_id} should be in orderChanges" + order = spot_tester.ws.order_changes[order_id] + assert order.symbol == spot_config.symbol + logger.info(f"✅ Order change received for {order_id}: status={order.status}") + + logger.info(f"✅ All {len(order_ids)} order changes received via WebSocket") + + # Verify REST matches WebSocket + open_orders = await spot_tester.client.get_open_orders() + open_order_ids = {o.order_id for o in open_orders if o.symbol == spot_config.symbol} + + for order_id in order_ids: + assert order_id in open_order_ids, f"Order {order_id} should be in REST open orders" + + logger.info("✅ REST open orders match WebSocket order changes") + + # Cleanup + for order_id in order_ids: + await spot_tester.client.cancel_order( + order_id=order_id, symbol=spot_config.symbol, account_id=spot_tester.account_id + ) + + await asyncio.sleep(0.1) + await spot_tester.check.no_open_orders() + + logger.info("✅ SPOT ORDER CHANGES WS INITIAL SNAPSHOT TEST COMPLETED") + + +# ============================================================================ +# SPOT EXECUTIONS CHANNEL SNAPSHOT TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_executions_ws_initial_snapshot( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test that subscribing to spotExecutions returns historical executions as snapshot. + + Flow: + 1. Execute a spot trade to create execution history (use external liquidity if available) + 2. Clear WebSocket spot executions tracking + 3. Reconnect/resubscribe to spotExecutions channel + 4. Verify historical executions are received in snapshot + """ + logger.info("=" * 80) + logger.info(f"SPOT EXECUTIONS WS INITIAL SNAPSHOT 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) + + # Refresh order book state to check for external liquidity + await spot_config.refresh_order_book(taker_tester.data) + + # Step 1: Execute a trade to create execution history + # Use external liquidity if available, otherwise create our own maker order + usable_bid = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid is not None: + # External bid liquidity exists - taker sells into it + logger.info(f"Using external bid liquidity at ${usable_bid}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(usable_bid)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) # Wait for execution to settle + logger.info("✅ Trade executed against external bid") + elif usable_ask is not None: + # External ask liquidity exists - taker buys into it + logger.info(f"Using external ask liquidity at ${usable_ask}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(usable_ask)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) # Wait for execution to settle + logger.info("✅ Trade executed against external ask") + else: + # No external liquidity - create our own maker order + logger.info("No external liquidity - creating maker order") + maker_price = spot_config.price(0.97) + 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) + logger.info(f"✅ Maker order created: {maker_order_id} @ ${maker_price}") + + taker_params = OrderBuilder.from_config(spot_config).sell().at_price(0.97).ioc().build() + taker_order_id = await taker_tester.orders.create_limit(taker_params) + logger.info(f"✅ Taker IOC order sent: {taker_order_id}") + + await asyncio.sleep(0.3) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Trade executed") + + # Step 2: Verify we received spot execution via WebSocket + assert len(taker_tester.ws.spot_executions) > 0, "Should have received spot execution via WebSocket" + logger.info(f"✅ Received {len(taker_tester.ws.spot_executions)} spot execution(s) via WebSocket") + + # Record the execution details for later verification + execution = taker_tester.ws.spot_executions[-1] + logger.info(f" Execution: symbol={execution.symbol}, side={execution.side}, qty={execution.qty}") + + # Step 3: Query executions via REST to confirm they exist + rest_executions = await taker_tester.client.get_spot_executions() + assert hasattr(rest_executions, "data") and len(rest_executions.data) > 0, "Should have executions in REST response" + logger.info(f"✅ REST API shows {len(rest_executions.data)} execution(s)") + + # Step 4: Verify execution data matches + # Find our execution in REST data + rest_exec = None + for e in rest_executions.data: + if e.symbol == spot_config.symbol: + rest_exec = e + break + + assert rest_exec is not None, f"Should find {spot_config.symbol} execution in REST data" + logger.info(f"✅ Found matching execution in REST: symbol={rest_exec.symbol}") + + # Verify WebSocket execution matches REST + assert execution.symbol == rest_exec.symbol, "Symbol should match" + logger.info("✅ WebSocket and REST execution data consistent") + + # Cleanup + await maker_tester.check.no_open_orders() + await taker_tester.check.no_open_orders() + + logger.info("✅ SPOT EXECUTIONS WS INITIAL SNAPSHOT TEST COMPLETED") + + +# ============================================================================ +# BALANCES CHANNEL SNAPSHOT TESTS +# ============================================================================ + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.asyncio +async def test_spot_balances_ws_initial_snapshot( + spot_config: SpotTestConfig, spot_tester: ReyaTester +): # pylint: disable=unused-argument + """ + Test that the balances WebSocket channel is subscribed and ready. + + Note: The WebSocket API does not send balance snapshots on subscription - + balances are only received as updates after trades. This test verifies + that the subscription is active and REST balances are available. + + Flow: + 1. Verify WebSocket is connected and subscribed to balances + 2. Get current balances via REST + 3. Verify REST balances are available for key assets + """ + logger.info("=" * 80) + logger.info("SPOT BALANCES WS SUBSCRIPTION TEST") + logger.info("=" * 80) + + # Get balances via REST for this specific account + rest_balances = await spot_tester.data.balances() + logger.info(f"REST balances for account {spot_tester.account_id}: {list(rest_balances.keys())}") + + # Verify REST balances are available + assert len(rest_balances) > 0, "Should have balances via REST" + logger.info("✅ REST balances available") + + # Verify key assets are present via REST + for asset in ["ETH", "RUSD"]: + if asset in rest_balances: + logger.info(f"✅ {asset} present in REST balances: {rest_balances[asset].real_balance}") + + # WebSocket balances are only populated after trades (updates, not snapshots) + # This is expected API behavior - balances channel sends updates, not initial snapshots + ws_balances = spot_tester.ws.balances + logger.info(f"WebSocket balances (populated after trades): {list(ws_balances.keys())}") + + # Note: We don't assert on WS balances here since they're only populated after trades + # The test_spot_balances_ws_update_after_trade test verifies WS balance updates work + + logger.info("✅ SPOT BALANCES WS SUBSCRIPTION TEST COMPLETED") + + +@pytest.mark.spot +@pytest.mark.websocket +@pytest.mark.maker_taker +@pytest.mark.asyncio +async def test_spot_balances_ws_update_after_trade( + spot_config: SpotTestConfig, maker_tester: ReyaTester, taker_tester: ReyaTester +): + """ + Test that balance updates are received via WebSocket after a trade. + + Flow: + 1. Record initial balance update counts + 2. Execute a trade (use external liquidity if available) + 3. Verify balance updates received via WebSocket + 4. Verify updated balances match REST + """ + logger.info("=" * 80) + logger.info("SPOT BALANCES WS UPDATE AFTER TRADE TEST") + logger.info("=" * 80) + + await maker_tester.orders.close_all(fail_if_none=False) + await taker_tester.orders.close_all(fail_if_none=False) + + # Clear balance updates + taker_tester.ws.clear_balance_updates() + + # Refresh order book state to check for external liquidity + await spot_config.refresh_order_book(taker_tester.data) + + # Execute a trade (use external liquidity if available) + usable_bid = spot_config.get_usable_bid_price_for_qty(spot_config.min_qty) + usable_ask = spot_config.get_usable_ask_price_for_qty(spot_config.min_qty) + + if usable_bid is not None: + # External bid liquidity exists - taker sells into it + logger.info(f"Using external bid liquidity at ${usable_bid}") + taker_params = OrderBuilder.from_config(spot_config).sell().price(str(usable_bid)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) # Wait for execution to settle + logger.info("✅ Trade executed against external bid") + elif usable_ask is not None: + # External ask liquidity exists - taker buys into it + logger.info(f"Using external ask liquidity at ${usable_ask}") + taker_params = OrderBuilder.from_config(spot_config).buy().price(str(usable_ask)).ioc().build() + await taker_tester.orders.create_limit(taker_params) + await asyncio.sleep(0.5) # Wait for execution to settle + logger.info("✅ Trade executed against external ask") + else: + # No external liquidity - create our own maker order + logger.info("No external liquidity - creating maker order") + maker_tester.ws.clear_balance_updates() + + 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(0.3) + await maker_tester.wait.for_order_state(maker_order_id, OrderStatus.FILLED, timeout=5) + logger.info("✅ Trade executed") + + # Wait for balance updates to propagate + await asyncio.sleep(0.3) + + # Verify balance updates received (only check taker since maker may not be involved with external liquidity) + taker_updates = taker_tester.ws.balance_updates + + logger.info(f"Taker balance updates: {len(taker_updates)}") + + # Taker should have received balance updates (ETH and RUSD) + assert len(taker_updates) >= 2, f"Taker should have at least 2 balance updates, got {len(taker_updates)}" + + # Verify assets updated + taker_assets = {u.asset for u in taker_updates} + + assert "ETH" in taker_assets, "Taker should have ETH balance update" + assert "RUSD" in taker_assets, "Taker should have RUSD balance update" + + logger.info("✅ Taker balance updates received for both ETH and RUSD") + + # Log balance values for debugging + await asyncio.sleep(0.5) # Wait for REST to catch up + + taker_rest_balances = await taker_tester.data.balances() + taker_ws_balances = taker_tester.ws.balances + + for asset in ["ETH", "RUSD"]: + if asset in taker_rest_balances and asset in taker_ws_balances: + rest_val = taker_rest_balances[asset].real_balance + ws_val = taker_ws_balances[asset].real_balance + logger.info(f"Taker {asset}: REST={rest_val}, WS={ws_val}") + + logger.info("✅ SPOT BALANCES WS UPDATE AFTER TRADE TEST COMPLETED")