Skip to main content

Asynchronous Python SDK for the RISEx perpetuals DEX (trading, market data, EIP-712 signing, WebSocket feeds).

Project description

RISEx Python SDK 🚀

Python 3.10+ Asyncio License: MIT

The RISEx Python SDK is an asynchronous library for the RISEx perpetuals DEX. It is aimed at trading bots, market makers, and analytics, and handles the awkward parts for you: EIP-712 signing, session-signer registration, and the exact request/payload formats the exchange expects.

Note: RISEx settles actions on-chain — each signed action (order, cancel, leverage, TPSL) is a blockchain transaction whose response includes a receipt and block number. It is fast and supports concurrent submission, but it is not a sub-millisecond CEX matching engine; size your latency expectations accordingly.


✨ Features

  • 🎯 One-call trading: open_long("BTC", 100, leverage=3, tp=..., sl=...) — USD sizing, symbol markets, TP/SL attached in the same call.
  • 🔁 Built-in retry: transient timeouts, disconnects, 5xx and 429s are retried with backoff; signed orders are never blindly resent.
  • Asynchronous: built on aiohttp + asyncio; signed actions accept a pre-fetched nonce so you can submit concurrently (see Concurrency).
  • 🔐 EIP-712 signing built in: builds, hashes and signs every action locally with eth-account / eth-abi — payloads are verified byte-for-byte against the live API.
  • 🔑 Automatic session signer: create() registers a throwaway session key so your main private key never signs individual orders.
  • 🛡️ Typed models: market data and account reads are pydantic models with IDE autocompletion. (State-changing calls return the raw response dict from the exchange.)
  • 📊 Full coverage: market data, account reads, orders, leverage/margin, and the complete TPSL lifecycle — all verified against mainnet (scripts/live_smoke_test.py).

Full technical reference — every method, wire format, retry rule and EIP-712 structure — lives in docs/.


📦 Installation

pip install risex

Or from source:

git clone https://github.com/Dmkls/risex.git
cd risex
pip install -e .

✅ Verify it works (live smoke test)

A self-contained script exercises every public method against the real mainnet API and prints a PASS/FAIL line per step plus a summary (exit code 0 if everything works):

# Safe: read-only endpoints only, no key needed
python scripts/live_smoke_test.py --read-only

# Full check: needs a funded test wallet. Opens AND closes a real position,
# and runs the full TPSL lifecycle. Always cleans up after itself.
export RISEX_PRIVATE_KEY=0x...
python scripts/live_smoke_test.py

⚠️ The full run places real orders and opens/closes a real position (tiny size, ~1.5x the market minimum). Use a dedicated test wallet with a small balance — it costs a little in fees/spread.


🚀 Quickstart

RISEx requires wallets to register a "signer" to place orders without signing metamask transactions every time. The SDK abstracts this entirely.

import asyncio
import os
from risex import RiseXClient

async def main():
    # Never hard-code a key. Read it from the environment (or a .env you load).
    private_key = os.environ["RISEX_PRIVATE_KEY"]

    # create() registers a temporary session signer for you automatically.
    client = await RiseXClient.create(private_key)
    print(f"Connected to RISEx as {client.account_address}")

    # Fetch your cross-margin balance (returns a float, in USDC)
    balance = await client.info.get_cross_margin_balance(client.account_address)
    print(f"Current Margin Balance: {balance} USDC")

    await client.close()

if __name__ == "__main__":
    asyncio.run(main())

Tip: both clients are async context managers, so you can let async with close the HTTP session for you:

async with await RiseXClient.create(private_key) as client:
    balance = await client.info.get_cross_margin_balance(client.account_address)

🎯 One-call trading (high-level API)

The simplest way to trade. Everything is sized in USD, markets are addressed by symbol, and entry + leverage + TP/SL happen in a single call:

async with await RiseXClient.create(private_key) as client:
    # Open a $100 LONG on BTC at 3x, with take-profit and stop-loss attached:
    await client.open_long("BTC", 100, leverage=3, tp=95_000, sl=55_000)

    # Or a short (tp/sl/leverage are all optional):
    await client.open_short("ETH", 50)

    # Read your own account without passing an address around:
    balance   = await client.get_balance()          # cross-margin USDC
    positions = await client.get_positions()        # all open positions
    pos       = await client.get_position("BTC")    # one market (None if flat)
    orders    = await client.get_open_orders()      # resting orders
    trades    = await client.get_trade_history()    # recent fills

    # Flatten one market — or everything:
    await client.close_position("BTC")
    await client.close_all_positions()

tp/sl are USD trigger prices, placed as TPSL trigger orders for the same size right after the entry (TPSL needs the one-time approve_permit_single_budget() — see Authentication below). If the entry succeeds but the TP/SL placement fails, the raised error says so explicitly, so you never silently lose protection.


🛡️ Reliability: built-in retries, timeouts & proxies

The HTTP layer retries transient failures automatically, so one flaky request doesn't kill your bot:

  • 429 rate limits — retried for every request, honoring Retry-After.
  • Timeouts, disconnects, 5xx — retried with exponential backoff + jitter for all reads (GET) and for idempotent setup calls (signer registration, terms).
  • Connect failures (DNS, refused, proxy down) — retried for everything: the request never reached the server, so a resend can't double-execute.
  • Order placement is never blindly resent: if a signed action times out, the server may still have processed it, so the SDK raises a clear RiseXConnectionError telling you to check open orders/positions first.

Defaults: 4 retries, 10s per-attempt timeout — both tunable on every factory:

client = await RiseXClient.create(pk, timeout_ms=15_000, max_retries=6)
info   = await RiseXInfoClient.create(max_retries=2)

Proxies. Pass proxy="http://user:pass@host:port" to any factory, or just set the standard HTTPS_PROXY environment variable (the SDK honors it). The API sits behind Cloudflare, which occasionally tarpits datacenter/flagged IPs for non-browser clients — if you see persistent timeouts while the website works, route the SDK through a clean proxy.


🔑 Authentication: main key vs. session signer ("API key")

RISEx never signs individual orders with your main wallet. Instead your main wallet registers a session signer — a throwaway keypair (think "API key") — and every order/cancel/leverage/TPSL action is signed by that signer. The main private key is only needed for one-time setup: registering the signer and approve_permit_single_budget.

So there are two ways to authenticate:

1. Main private keyRiseXClient.create(private_key). Generates and registers a session signer for you automatically. Use this for first-time setup, or if you're fine keeping the main key on the trading machine.

2. Address + session-signer key (no main key)RiseXClient.from_signer(account_address, signer_key). The bot authenticates with just your public address and the signer key, so the main private key never touches the trading machine. This is the recommended setup for bots.

# --- one-time, on a trusted machine: register a signer and save it as your API key ---
client = await RiseXClient.create(main_private_key)
api_key = client.signer_key          # <-- save this (it's the session signer's private key)
await client.approve_permit_single_budget()   # one-time, enables TPSL triggers
await client.close()

# --- afterwards, on the bot: address + API key only, NO main key ---
async with await RiseXClient.from_signer(account_address, api_key) as client:
    await client.market_buy_usd("BTC", 100)    # signed by the session key

The signer key can trade but is registered with an expiry (≈30 days), after which you re-register with create(main_key). approve_permit_single_budget and signer registration are unavailable on a from_signer client (they need the main key) and raise a clear RiseXError. Treat the signer key as a secret.

Managing signers. List the signers registered to an account and revoke ones you no longer use:

for s in await info.get_signers(account):          # signer, label, expiration, status
    print(s.signer, s.status)

await client.revoke_signer(old_signer_address)     # needs the main key; status -> "Revoked"

📐 Units: steps & ticks

Order endpoints do not take USD. Size is an integer count of steps and price is an integer count of ticks, where each market defines the increment:

asset_size = size_steps * market.config.step_size
price_usd  = price_ticks * market.config.step_price

💡 Prefer the USD helpers. If you just want to trade in dollars, skip this whole section and use market_buy_usd / limit_sell_usd etc. (see Placing Orders) — they do the conversion for you. The manual path below is for when you need exact step/tick control.

The lowest-level helpers convert explicitly. You no longer have to fetch and filter the market list yourself — pass a market id or symbol to info.get_market(...):

from risex import RiseXInfoClient, usd_to_steps, price_to_ticks, steps_to_size, ticks_to_price

info = await RiseXInfoClient.create()
market = await info.get_market("BTC")          # by symbol; or get_market(1) by id

size_steps  = usd_to_steps(100, market)        # $100 of notional -> steps (floored)
price_ticks = price_to_ticks(85_000, market)   # $85,000 -> ticks (rounded)

# ...and back the other way:
size_in_btc = steps_to_size(size_steps, market)
price_in_usd = ticks_to_price(price_ticks, market)

On the trading client there are one-call shortcuts that resolve the market for you: await client.to_steps("BTC", 100) and await client.to_ticks("BTC", 85_000).

⚠️ size_steps / price_ticks returned inside OpenOrder are the backend's internal encoded form — they do not equal the values you submitted and won't round-trip through these helpers. Use them only as opaque fields; read real sizes/prices from positions, fills, or the orderbook.


📖 Public Data: RiseXInfoClient

The RiseXInfoClient handles all public API endpoints. It does not require a private key.

Initialization

from risex import RiseXInfoClient
info = await RiseXInfoClient.create()

🔢 Numeric fields are Decimal, not strings. Prices, sizes and amounts across the typed models — market (last_price, mark_price, step_size, step_price, max_leverage, min_order_size), position (size, quote_amount), orderbook level (price, quantity) and trade (price, size, fee, realized_pnl, avg_price, leverage) — are parsed as Decimal. You can compare and compute on them directly (pos.size > 0, market.mark_price * 2, f"{lvl.price:.2f}") with no casting. (Identifiers/timestamps like trade.time stay strings.)

1. Market Data

get_markets()

Returns a list of all available trading pairs (markets) on RISEx. Results are cached briefly (markets_cache_ttl, default 5s) so repeated lookups and USD sizing don't hit the network every call; pass refresh=True to force a refetch.

markets = await info.get_markets()
for market in markets:
    print(f"[{market.market_id}] {market.display_name} - Mark Price: {market.mark_price}")

get_market(market)

Resolve a single market by numeric id or by symbol (case-insensitive, matched against the display name / base token). Raises RiseXValidationError listing the available symbols if nothing matches.

btc = await info.get_market("BTC")     # same as get_market(1) or get_market("BTC-PERP")
print(btc.market_id, btc.config.step_size)

get_orderbook(market_id: int, limit: int = 20)

Fetches the current orderbook snapshot (limit price levels per side).

ob = await info.get_orderbook(market_id=1, limit=10)  # BTC/USDC
print(f"Best Bid: {ob.bids[0].price}, Best Ask: {ob.asks[0].price}")
# each level also carries .order_count; the snapshot has .total_bids / .total_asks

2. Account Information

get_cross_margin_balance(account_address: str)

Get the available USDC margin balance for an account.

balance = await info.get_cross_margin_balance("0xYourAddress")

get_position(account_address: str, market_id: int)

Get the open position for an account in a market. Returns None if flat.

position = await info.get_position("0xYourAddress", market_id=1)
if position and position.size != 0:   # size is a Decimal — no float() cast needed
    print(f"Size: {position.size}, Side: {position.side}")  # side: 0 = long, 1 = short
else:
    print("No open position")

get_all_positions(account_address: str)

Get all open positions across all markets for a specific account. Each Position carries its market_id and is normalized to the same human form as get_position (decimal size/quote_amount, int side). The backend reports this endpoint in on-chain 18-decimal fixed-point with a string side; the SDK converts it for you. Extra per-position fields (e.g. leverage, avg_entry_price) are scaled and available via position.model_extra.

for p in await info.get_all_positions("0xYourAddress"):
    side = "long" if p.side == 0 else "short"
    print(f"market {p.market_id}: {side} {p.size} @ {p.model_extra.get('avg_entry_price')}")

get_open_orders(account_address: str)

Retrieve all currently resting limit orders for an account.

orders = await info.get_open_orders("0xYourAddress")
for order in orders:
    # Use order_id (hex) to cancel; size_steps/price_ticks here are encoded — see the units note above.
    print(f"Order {order.order_id} (resting_order_id={order.resting_order_id})")

get_trade_history(account_address: str, limit: int = 50, market_id: int | None = None)

Get the account's most recent fills (optionally filtered to one market). Each TradeRecord carries side ("BUY"/"SELL"), price, size, fee, time (unix nanoseconds, as a string), plus realized_pnl, avg_price, liquidity_indicator ("MAKER"/"TAKER"), and leverage.

trades = await info.get_trade_history("0xYourAddress", limit=50)
for t in trades:
    print(f"{t.side} {t.size} @ {t.price}  pnl={t.realized_pnl} ({t.liquidity_indicator})")

⚔️ Trading: RiseXClient

The RiseXClient is required for any state-modifying actions (trading, changing leverage). It inherits all methods from the RiseXInfoClient under client.info.*.

Account Management

update_leverage(market_id: int, leverage: int)

Updates the account's leverage multiplier for a specific market.

# Set leverage to 10x on BTC/USDC
await client.update_leverage(market_id=1, leverage=10)

update_margin_mode(market_id: int, margin_mode: MarginMode | int)

Change the margin mode. MarginMode.CROSS == 0, MarginMode.ISOLATED == 1.

from risex import MarginMode

await client.update_margin_mode(market_id=1, margin_mode=MarginMode.ISOLATED)

Setting the mode that's already active reverts on-chain with MarginModeUnchanged — catch RiseXAPIError if that's expected.

update_isolated_margin(market_id: int, amount: int)

Add (amount > 0) or remove (amount < 0) isolated margin for a market. Only valid in isolated mode — in cross mode it reverts with …BalanceInCross.

await client.update_isolated_margin(market_id=1, amount=5_000_000)  # add margin

deposit(amount: str)

Notify the backend of a deposit. The actual USDC transfer must be made on-chain to the router contract separately; this only registers it.

await client.deposit("100")

Placing Orders

🎯 Every method below accepts a market id or a symbol ("BTC", "ETH-PERP", …) wherever it takes market_id. So client.market_buy("BTC", …) and client.update_leverage("ETH", 10) both work — no need to remember numeric ids.

USD helpers (the easy way) 💵

Trade in dollars and let the SDK convert to steps/ticks for you. Size is a USD notional; price is a USD price.

# Market buy $100 of BTC
await client.market_buy_usd("BTC", 100)

# Rest a $250 limit sell of ETH at $4,000 (post-only maker)
await client.limit_sell_usd("ETH", 250, 4_000)

# Also: market_sell_usd(market, usd), limit_buy_usd(market, usd, price)

Lower-level: size_steps / price_ticks

If you need exact step/tick control, the methods below take integer size_steps and price_ticks — see Units: steps & ticks for converting from USD.

limit_buy / limit_sell

Place a limit order at a specific price.

# Place a limit buy order of size 0.01 at price 85000
res = await client.limit_buy(
    market_id=1,
    size_steps=1000,     # Amount of tokens (scaled by market step size)
    price_ticks=850000,  # Price (scaled by market tick size)
    post_only=True       # Ensure maker fee
)
print(f"Order placed: {res.order_id}")

market_buy / market_sell

Execute an immediate market order.

# Market buy
res = await client.market_buy(market_id=1, size_steps=500)

Risk Management (TP/SL)

Side convention: the order side is expressed with Side.LONG (maps to a BUY trigger) and Side.SHORT (maps to a SELL trigger). To reduce a LONG position you submit a SELL, i.e. Side.SHORT.

approve_permit_single_budget(budget=MAX_UINT96, expiry_seconds=1yr)

One-time setup so the exchange operator can submit your TPSL triggers on your behalf. Signed by your main wallet; returns the approval tx plus fresh access/refresh tokens. Call it once before placing trigger orders.

await client.approve_permit_single_budget()

place_stop_loss(market_id, side, size, stop_price, limit_price)

Place a Stop-Loss order to protect an open position.

from risex.enums import Side

# If you are LONG on BTC (bought at 90,000), place a SELL stop-loss at 88,000
await client.place_stop_loss(
    market_id=1,
    side=Side.SHORT,   # SELL, to reduce a LONG
    size="0.1",
    stop_price="88000",
    limit_price="87900"
)

place_take_profit(market_id, side, size, stop_price, limit_price)

Place a Take-Profit as a TPSL trigger order (taker fee when it fires).

# Take profit for a LONG BTC position at 95,000
await client.place_take_profit(
    market_id=1,
    side=Side.SHORT,   # SELL, to reduce a LONG
    size="0.1",
    stop_price="95000",
    limit_price="95100"
)

take_profit_limit(market_id, side, size_steps, price_ticks, post_only=True)

Take profit as a resting maker reduce_only limit order — lower fees than a TPSL trigger. Prefer this when you can wait for the price to come to you.

# Maker take-profit on a LONG: rest a SELL reduce_only limit at the target
await client.take_profit_limit(
    market_id=1,
    side=Side.SHORT,       # SELL, to reduce a LONG
    size_steps=1000,
    price_ticks=950000,
)

close_position(market_id)

Automatically calculates your current position size and places a market order in the opposite direction to flatten your exposure entirely.

# Close all open exposure on BTC/USDC
await client.close_position(market_id=1)

Order Cancellation

cancel_order(market_id, order_id, resting_order_id=None)

Cancel a specific resting order. Pass the hex order_id from place_order / get_open_orders; the SDK looks up the numeric resting_order_id it needs to sign (pass it explicitly to skip that extra lookup).

res = await client.limit_buy(market_id=1, size_steps=1000, price_ticks=850000)
await client.cancel_order(market_id=1, order_id=res.order_id)

cancel_all_orders(market_id)

Cancel all open orders on a specific market in a single transaction. A market must be specified (the backend has no all-markets cancel — call it per market).

# Cancel all orders on BTC/USDC
await client.cancel_all_orders(market_id=1)

📡 Real-time data: WebSocket

RiseXWebSocketClient streams live data over wss://api.rise.trade/ws/ (built on aiohttp — no extra dependency). It auto-reconnects and replays your subscriptions, and delivers messages either by async for iteration or per-channel callbacks.

Channel Auth Subscribe with Payload
system no subscribe_system() maintenance status
orderbook no subscribe_orderbook([1,2]) bids/asks per market_id
trades no subscribe_trades([1]) fills on the market
oracle no subscribe_oracle() index/mark prices (all markets, 1e18-scaled)
positions no¹ subscribe_positions([addr],[1]) position updates for the address
orders no¹ subscribe_orders([addr],[1]) order lifecycle for the address
account, fills yes² subscribe_account(), subscribe_fills() balance/equity / your fills

¹ positions/orders are filtered by maker address and need no login (same as the REST by-address reads). ² account/fills require an auth_v2 sign-in — one call, see below.

from risex import RiseXWebSocketClient

async with RiseXWebSocketClient() as ws:
    await ws.subscribe_orderbook([1])          # BTC/USDC book
    await ws.subscribe_trades([1])
    await ws.subscribe_positions([my_address], [1])

    async for msg in ws:                        # msg is a WSMessage
        if msg.is_ack or msg.is_error:
            continue
        if msg.channel == "orderbook":
            best_ask = msg.data["asks"][0]
            print(f"[{msg.market_id}] best ask {best_ask['price']} x {best_ask['quantity']}")
        elif msg.channel == "positions":
            print("position update:", msg.data)

Each message is a WSMessage with channel, type ("snapshot"/"update"/…), market_id, block_number, and data (the channel payload — a dict or list). Prefer callbacks for a long-running bot:

ws = RiseXWebSocketClient()
ws.on("trades", lambda m: print("trade", m.data))
ws.on("orders", on_my_order)        # sync or async handlers
await ws.connect()
await ws.subscribe_trades([1])
await ws.subscribe_orders([my_address], [1])
await ws.run_forever()

Authenticating account / fills

These two channels need an auth_v2 sign-in: the SDK fetches a single-use nonce, signs it (EIP-712 RegisterV2) with your session signer, and re-authenticates automatically on reconnect. With a RiseXClient it's one call:

ws = await client.connect_websocket()   # already authenticated with the session signer
await ws.subscribe_fills()
await ws.subscribe_account([client.account_address])
async for msg in ws:
    if msg.channel == "fills":
        print("fill:", msg.data)        # side, price, size, fee, liquidity_indicator, ...

Or authenticate a standalone RiseXWebSocketClient yourself with an info client and a signer key (your "API key"):

info = await RiseXInfoClient.create()
ws = await RiseXWebSocketClient().connect()
await ws.authenticate(account_address, signer_key, info)
await ws.subscribe_fills()

info.get_signers(account) lists the registered signers (label, expiry, status) if you need to pick or check one. Without authenticate(), subscribe_account/subscribe_fills return an authentication required error (a WSMessage with is_error); every other channel works without it.


🔀 Concurrency & nonces

By default each signed action (place_order, cancel_order, cancel_all_orders) fetches the account nonce first, which adds a round-trip. To submit several actions concurrently — or to skip that fetch — pass a pre-fetched NonceState via the nonce argument:

nonce = await client.get_nonce_state()
await asyncio.gather(
    client.place_order(market_id=1, side=Side.LONG, size_steps=100, nonce=nonce),
    client.place_order(market_id=2, side=Side.SHORT, size_steps=50, nonce=nonce),
)

Each signed action consumes a nonce slot on-chain, so when firing many at once you are responsible for advancing/spacing nonces to avoid collisions.

Logging

The SDK logs requests (and, at DEBUG, payloads) under the risex.http and risex.client loggers — they're silent unless you enable them:

import logging
logging.getLogger("risex").setLevel(logging.DEBUG)

🚨 Error Handling

All API errors and smart contract reverts throw a RiseXAPIError exception, which includes the HTTP status code, request path, and decoded error message from the EVM.

from risex.exceptions import RiseXAPIError

try:
    await client.market_buy(market_id=1, size_steps=100000000)
except RiseXAPIError as e:
    print(f"Failed! Status: {e.status}")
    print(f"Message: {e.message}") # e.g. 'CancelOrder reverted: SignerNotAuthorized'

📄 License

This SDK is distributed under the MIT License. See LICENSE for more information.

Disclaimer: This SDK interacts with live decentralized financial infrastructure. High-frequency trading carries significant financial risk. The authors are not responsible for any financial losses or unexpected smart contract behavior resulting from the use of this software. Always test thoroughly using small amounts before deploying to production.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

risex-0.10.0.tar.gz (86.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

risex-0.10.0-py3-none-any.whl (45.3 kB view details)

Uploaded Python 3

File details

Details for the file risex-0.10.0.tar.gz.

File metadata

  • Download URL: risex-0.10.0.tar.gz
  • Upload date:
  • Size: 86.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.2

File hashes

Hashes for risex-0.10.0.tar.gz
Algorithm Hash digest
SHA256 61a6746d8481433a6648a4d9fc0f5cf7864f6de503105a97d5eb8b1b97772ac7
MD5 46a0c294cd0ef8280e35647dbda4ca79
BLAKE2b-256 41a6683b77223816ca3bb7c58c1003b2be55a91ac40727a31117ebae4b5a0263

See more details on using hashes here.

File details

Details for the file risex-0.10.0-py3-none-any.whl.

File metadata

  • Download URL: risex-0.10.0-py3-none-any.whl
  • Upload date:
  • Size: 45.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.2

File hashes

Hashes for risex-0.10.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d307c509959f3d1586a83a58329ec00e0735f26d3af2b1dd2391a14594263ebc
MD5 475ca8c2edabd14963d335f8bef83cdd
BLAKE2b-256 d4e6686494a3178907280f36b7662e0f9e630c327a543703b841de97b10d7204

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page