Skip to main content

nexus-exchange (Python)

License

Official Python SDK for the Nexus Exchange API — a thin, typed wrapper over the public REST API.

⚠️ Experimental / in development. This is an early skeleton. The surface is small and may change without notice; only the endpoints in the table below are implemented. For the complete, ahead-of-this surface use the Rust SDK. This SDK exists so agents and bots can be written in Python or Rust depending on the libraries they need.

Install

pip install nexus-exchange   # once published; for now, install from source:
pip install git+https://github.com/nexus-xyz/nexus-exchange-py

Requires Python 3.10+. Depends only on httpx.

Quick start

from nexus_exchange import Client

with Client() as client:  # defaults to the public gateway
    for market in client.fetch_markets():
        print(market.market_id)

    ticker = client.fetch_ticker("BTC-USDX-PERP")
    print(ticker.last, ticker.mark_price)

No credentials are needed for market data. See examples/public_market_data.py.

What's supported

Area Status
Markets — GET /markets, /markets/summary, /tickers ✅ implemented
Ticker — GET /markets/{id}/ticker ✅ implemented
Order book — GET /markets/{id}/orderbook ✅ implemented
Trades — GET /markets/{id}/trades ✅ implemented
OHLCV candles — GET /markets/{id}/candles ✅ implemented
Funding / mark price / status — GET /markets/{id}/{funding,mark-price,status} ✅ implemented
ADL events — GET /markets/{id}/adl-events, /account/{addr}/adl-history ✅ implemented
Health — GET /health ✅ implemented
HMAC request signing (the plumbing for authed calls) ✅ implemented
Wallet-signed auth — sign_in (EIP-191) + register_agent (EIP-712) ✅ implemented
CCXT-compatible adapter — public market data ✅ implemented
Error taxonomy (terminal vs transient, incl. the jurisdiction 403) ✅ implemented
Typed money — Decimal prices/sizes (full payload still on .raw / .info) ✅ implemented
Account reads — GET /account, /positions, /positions/closed, /fills, /withdrawals, /account/rate-limit ✅ implemented
Portfolio — GET /account/state (summary + positions, incl. withdrawable), /account/summary, /account/fees, /account/portfolio-history, /account/equity-history ✅ implemented
Trading — POST /orders, /orders/batch; GET /orders, /orders/{id}, /orders/history; DELETE /orders, /orders/{id} ✅ implemented
Funds — POST /account/deposit, /account/credit ✅ implemented
Bridge — GET /bridge/assets, /bridge/deposits(/{id}); POST/GET /bridge/deposit-addresses ✅ implemented
Keys / agents / WS token — /keys, /agents, POST /ws-tokens ✅ implemented
Admin tiers — GET/PUT/DELETE /admin/tiers ✅ implemented
Cursor pagination — cursor + X-Next-Cursor on all five paginated GETs ✅ implemented
WebSocket streaming ❌ not yet
Rate-limit-aware retry (429 / Retry-After, token bucket) ❌ not yet
OAuth auth ❌ not yet

The hand-maintained coverage source of truth is endpoints.txt. Anything not listed there is not wrapped yet — contributions welcome.

Networks

Network is the network axis — which chain, and whose money:

Network Funds Faucet Notes
Network.TESTNET Play (synthetic USDX) Yes Default. The safe target for integration work and CI.
Network.MAINNET Real No Collateral is USDX bridged from Ethereum Mainnet.
Network.LOCAL Play Yes A locally run indexer. Not a public network.

Each member bundles its config — REST bases, both WebSocket bases, funds semantics and the EIP-712 signing domain:

from nexus_exchange import Client, Funds, Network

Network.TESTNET.ws_market_data_url  # 'wss://api.testnet.nexus.xyz/stream'
Network.TESTNET.ws_authenticated_url  # 'wss://api.testnet.nexus.xyz/ws'
Network.MAINNET.funds  # Funds.REAL — branch on this, never on the host string

with Client(Network.TESTNET) as client:
    ...

funds is a tri-state, not a boolean: Funds.REAL, Funds.PLAY, or Funds.UNKNOWN for a target whose funds were never declared. Guard on PLAY positively so an undeclared target fails closed:

if client.network.funds is not Funds.PLAY:  # correct — UNKNOWN is refused
    refuse()
if client.network.funds is Funds.REAL:  # WRONG — UNKNOWN slips through
    refuse()

Whether a faucet exists is tracked separately (has_faucet): "not real money" does not imply "can mint more of it".

The two WebSocket bases and published_rest_base are the spec's durable per-network values, recorded here so they live in one place. The hosted ones do not resolve yet (DNS is not configured), and this SDK ships no WebSocket client, so treat them as published targets rather than something to connect to today. What the client actually sends to is base_url / direct_base_url.

Three things worth knowing before you pick one:

  • Mainnet has no default base URL yet. Its host (api.nexus.xyz) is published but DNS is not live, so Client(Network.MAINNET) raises rather than guessing a real-funds target or quietly falling back to testnet. Pass base_url=… explicitly to target it.
  • Credentials never cross networks. Session tokens, HMAC keys and agent keys are minted per network and are invalid on any other. Switching network means a new client and new credentials — never carry a signature, nonce or agent registration across.
  • The signing domain's chain_id is not published statically. Read it from the edge's /metadata for the network you are on. register_agent refuses to sign without one rather than defaulting: a wrong domain either fails verification or produces a signature valid on a different network.

The retired stable / beta release channels were never networks. stable became Network.TESTNET (same target); beta is now a custom target:

Client(
    NetworkConfig.custom(
        label="beta",
        funds=Funds.UNKNOWN,  # that deploy's funds are not ours to assert
        base_url="https://beta.exchange.nexus.xyz/api/exchange",
        # direct_base_url defaults to base_url, which is the right shape for a
        # gateway deploy: the /api/v1 surface is mounted under the prefix, not at
        # the host root. Only set it if you have measured that deploy serving the
        # two surfaces apart.
    )
)

Custom targets

For a deployment this SDK ships no hostname for, build the config yourself and pass it wherever a Network goes. It carries the whole bundle, not just a URL — that is what stops a client reporting play-funds guardrails while pointed somewhere else:

from nexus_exchange import Client, Funds, NetworkConfig

config = NetworkConfig.custom(
    label="dev",  # required
    funds=Funds.PLAY,  # required — no default
    base_url="https://exchange.example.com",
    direct_base_url="https://exchange.example.com",  # optional; defaults to base_url
    has_faucet=False,  # absent until declared
    chain_id=None,  # unknown ⇒ signing refuses
)

with Client(config) as client:
    ...

label and funds are required and have no defaults. There is no safe default for funds: assuming play makes every guardrail lie on a real-funds deployment, and assuming real makes a dev target unusable. Pass Funds.UNKNOWN when it truly is unknown — the guards treat that as unsafe, which is the honest answer.

label is validated ([A-Za-z0-9._-], max 64, no . or ..) because it is a key: it is what stored credentials are namespaced under across these SDKs, so a label that can escape a directory or split a keyring entry would let one target address another's credentials.

Both base URLs are validated for scheme and host, and refused if they carry userinfo or a query or fragment. The request path is appended to the base, so https://host?a=1 would be sent and signed as https://host?a=1/api/v1/orders, and https://api.nexus.xyz@evil.com reads as the published host to anyone skimming a config file while the requests — and the API keys — go to evil.com. A path is accepted: a base under /api/exchange is a real, working topology. The same checks apply to a raw base_url / direct_base_url override, including the one mainnet requires.

A bare base_url with no network named is deprecated (#61) — build the config instead. Both reach the same host; only the config says what is behind it, so the bare form yields Funds.UNKNOWN and no faucet, and every guard treats that as unsafe:

Client(base_url="https://exchange.example.com")  # deprecated: UNKNOWN, no faucet
Client(NetworkConfig.custom(label="dev", funds=Funds.PLAY, base_url="https://exchange.example.com"))

It still works, unchanged, and does not warn at runtime — the marker each SDK carries was chosen per ecosystem, and Python's is prose. So if you do not read this section you get no signal at all, which is exactly why a release that warns has to come before one that removes it: a real DeprecationWarning, which Python shows by default when the caller is __main__, i.e. in the local scripts and notebooks this form exists for. Nothing is removed here, and nothing is removed before that runway has shipped.

What is deprecated is the selector — a URL that picks the target on its own. direct_base_url is a modifier and stays, and so does a URL passed alongside a named network, which keeps that network's semantics because the caller has declared them:

Client(Network.LOCAL, base_url="http://127.0.0.1:8080")  # stays play funds + faucet
Client(Network.MAINNET, base_url="https://api.nexus.xyz")  # stays real funds

Custom configs are never added to the network map and are not addressable by name — Network("dev") still raises.

Routing: direct /api/v1 service vs. legacy gateway

As the REST gateway is retired, backend services expose their own REST API under an /api/v1 prefix. That prefix is a path, not a host: it is mounted wherever the deployment serves the direct service, which on the hosted deploy is under the …/api/exchange gateway prefix (https://exchange.nexus.xyz/api/exchange/api/v1/…) and on a direct indexer host is the bare origin. The client appends /api/v1 to direct_base_url, so that field carries whichever base applies. The migrated market-data and account/trading routes now target this direct service; the HMAC signature covers the full path (e.g. /api/v1/orders), independent of the base. Routes with no /api/v1 equivalent yet — GET /markets, /health, ADL history, GET /orders/{id}, deposits, keys/agents, WS tokens and admin tiers — stay on the legacy gateway. This split is internal; method names and signatures are unchanged. A custom base_url overrides both bases; pass direct_base_url alongside it to target a deploy that serves the two surfaces apart.

Either topology is accepted. A gateway-prefixed direct_base_url used to be rejected at construction, on the premise that /api/v1 is served only at the host root. Production measurement says otherwise (rs#131): …/api/exchange/api/v1/markets/summary answers 200 application/json while …/api/v1/markets/summary answers 404 text/html, and junk segments under the gateway prefix answer a JSON NOT_FOUND — so the gateway mounts /api/v1 specifically rather than routing permissively. A direct indexer host plausibly serves it at the root too, so both are real and which applies is a property of the URL, not something this client can assert. The rejection made the working configuration unreachable on the deploy targeted by default, so it is gone (#60).

If you are coming from another Nexus SDK

The field names differ, so line them up before copying a base URL across — the two-URL split here is one field in the TypeScript client. Every field below holds a deployment base with no /api/v1 — Python, TypeScript and Rust all append that prefix themselves, on the direct routes only:

Surface Python TypeScript Base value (testnet) Composed URL
Direct /api/v1 service direct_base_url baseUrl https://exchange.nexus.xyz/api/exchange …/api/exchange/api/v1/orders
Legacy /api/exchange gateway base_url not modelled https://exchange.nexus.xyz/api/exchange …/api/exchange/ws/token

On this deploy all of these hold the same string, because the direct surface is mounted under the gateway prefix — so copying a base across the three SDKs gives the right answer today, and Python's two fields stay separate only so a deploy that does serve the surfaces apart can still say so.

What does not survive the copy is a base that already carries /api/v1 — including the value Network.TESTNET.direct_base_url composes to, and TypeScript baseUrl's own pre-0.3 default. Every SDK appends the prefix itself, so such a base sends /api/v1/api/v1/orders while signing the correct /api/v1/orders: a routing failure whose signature looks fine. TypeScript rejects it at construction; Python does not check, so strip the prefix before pasting a URL into base_url or direct_base_url.

Authentication

Signed requests use the canonical HMAC-SHA256 scheme the exchange verifies:

<timestamp>\n<METHOD>\n<path>\n<query>\n<sha256hex(body)>

signed with the hex-decoded secret, sent as x-signature with x-api-key and x-timestamp. Pass api_key / api_secret to Client. Note the default public gateway proxies signed calls to the site account; to act as a specific account, point base_url (or Network.LOCAL) at a direct gateway that verifies client HMAC. Typed authed methods are not built yet — Client._request(..., signed=True) is the low-level escape hatch in the meantime.

Wallet-signed auth

The HMAC scheme above signs requests with an API key. The two wallet-authorized flows are different: an EVM wallet key authorizes a session or an agent key, with the signature carried in the request body (these POSTs are themselves unauthenticated). This mirrors the Rust SDK's EthSigner and the digests are cross-checked, byte-for-byte, against the server's known-answer vectors.

EthSigner is a pure signer — the caller supplies the private key (a library pattern; there is no key prompt or file handling). It needs the eth-account dependency, which ships with the SDK.

from nexus_exchange import Client, EthSigner

signer = EthSigner.from_hex("0x<wallet-private-key>")  # you own the key

with Client() as client:
    # EIP-191 personal_sign → POST /auth/login → session token.
    session = client.sign_in(signer)
    print(session.address, session.token)  # token is a secret

    # EIP-712 → POST /agents/register. expires_at_ms / nonce / chain_id are
    # caller-supplied; expiry must fall in [now + 1d, now + 90d].
    registration = signer.register_agent(
        agent="0x<agent-address>",
        expires_at_ms=1_782_000_000_000,
        nonce=1,
        chain_id=393,
        label="my-bot",
    )
    registered = client.register_agent(registration)
    print(registered.agent_address, registered.expires_at)

Bridge

Deposit funds across chains via the /bridge surface (USDC/USDX in Phase A). Get a deposit address (idempotent per account + chain), send funds, then poll a deposit until status is credited:

assets = client.fetch_bridge_assets()
addr = client.create_bridge_deposit_address(assets.chains[0].chain)
print(f"send USDC/USDX to {addr.address} on {addr.chain}")

deposits = client.fetch_bridge_deposits(limit=1, chain=addr.chain)
# deposits[0].status: "detected" | "confirming" | "credited" | "failed"

See examples/bridge_deposit.py.

Portfolio

One signed call returns the whole account state — summary aggregates plus every open position, from a single coherent read:

from nexus_exchange import PortfolioWindow

state = client.fetch_account_state()
print(state.summary.total_equity, state.summary.withdrawable)  # None if unreported
for pos in state.positions:
    # Enriched risk detail; None + a `*_error` reason when not derivable.
    print(pos.market_id, pos.notional_value, pos.roe, pos.funding_paid)
    print(pos.leverage, pos.leverage_error)  # None, "margin_state_not_mirrored"

summary = client.fetch_account_summary()  # the aggregates alone, no positions
print(summary.withdrawable)

fees = client.fetch_account_fees()
print(fees.maker_fee_bps, fees.taker_fee_bps)  # maker may be negative (a rebate)

history = client.fetch_portfolio_history(PortfolioWindow.WEEK, limit=100)
for point in history.points:  # oldest first
    print(point.timestamp_ms, point.equity, point.pnl, point.volume)

withdrawable is engine-authoritative free margin floored at zero. The endpoints serving it fail closed with 502 authoritative_margin_unavailable (an ApiError) rather than returning a local estimate — that is transient, so retry rather than substituting a self-computed figure.

Every money field is a Decimal, and decoding never invents one. A field the spec marks optional decodes to None when unreported, never a defaulted 0 that would read as a real balance — while a reported "0" stays Decimal(0). A field the spec marks required decodes strictly: if it is absent, null or malformed, the call raises DecodeError (a NexusExchangeError, and a ValueError) rather than handing back a plausible figure the server never sent. That extends to lists — a malformed point or position raises instead of silently dropping out of the series.

One 4xx is worth catching by itself. A jurisdiction control refuses state-changing calls — and, on the sanctions list, reads too — with a 403 that is permanent for your origin, so retrying it never helps:

from nexus_exchange import RestrictedJurisdictionError

try:
    client.create_order(order)
except RestrictedJurisdictionError as err:
    # US_RESTRICTED | GEO_UNRESOLVED | RESTRICTED_JURISDICTION — and the list is
    # open, so treat anything unrecognized as permanent too. Never match on
    # `err.message`; the spec marks its wording unstable.
    print("refused:", err.block_reason)

It subclasses ApiError, so an existing except ApiError still catches it.

Pagination

The list endpoints return a page of results plus an opaque cursor for the next page, carried in the X-Next-Cursor response header (spec v0.7.2). All five paginated endpoints are wrapped, each with a flat fetch_* (first page), an iter_* (every page) and a fetch_*_page (one page + its cursor):

endpoint methods limit max
GET /markets/{id}/trades fetch_trades / iter_trades / fetch_trades_page 1000
GET /fills fetch_my_trades / iter_my_trades / fetch_my_trades_page 1000
GET /orders/history fetch_order_history / iter_order_history / fetch_order_history_page 500
GET /positions/closed fetch_closed_positions / iter_closed_positions / fetch_closed_positions_page 200
GET /account/equity-history fetch_equity_history / iter_equity_history / fetch_equity_history_page 720 (also the default)

iter_* walks every page for you, lazily — one request per page, driven by the cursor:

for fill in client.iter_my_trades(limit=500):  # limit = page size, not a total
    print(fill.id, fill.price, fill.size)

# Stop early and the requests stop with you.
for trade in client.iter_trades("BTC-USDX-PERP", limit=100, max_pages=5):
    ...

fetch_*_page is the manual form, for when the cursor has to outlive the process (a resumable backfill):

page = client.fetch_my_trades_page(limit=500)
save_checkpoint(page.next_cursor)  # None once page.is_last
page = client.fetch_my_trades_page(limit=500, cursor=load_checkpoint())

Cursors are opaque — never parse one. Termination rules:

  • No X-Next-Cursor header ⇒ the last page. Not an error, and not a reason to retry.
  • An empty page that still carries a cursor is not the end — a sparse window keeps paging.
  • A server that hands back the same cursor it was given cannot advance, so the walk raises PaginationError instead of re-requesting one page forever. A silent stop would report a truncated history as complete.
  • Nothing else bounds how far back a walk goes; pass max_pages when that matters.

limit sets the page size and is validated against that endpoint's spec maximum before the request (TRADES_LIMIT_MAX, FILLS_LIMIT_MAX, ORDER_HISTORY_LIMIT_MAX, CLOSED_POSITIONS_LIMIT_MAX, EQUITY_HISTORY_LIMIT_MAX — see the table above). They are not interchangeable: 500 is valid on /orders/history and out of range on /positions/closed. On /account/equity-history the maximum is also the server's default, so one page normally covers the whole ~1h window.

In particular the 366 bound belongs only to /account/portfolio-history (PORTFOLIO_LIMIT_MAX), which has no cursor parameter at all — applying it to a paginated endpoint would reject valid requests, and on /account/equity-history it sits below the server's own default of 720.

CCXT compatibility

CCXT is the unified API the Python quant/retail stack (freqtrade, hummingbot, bots) speaks. nexus_exchange.ccxt_adapter exposes the exchange under CCXT's unified method names and return shapes, so CCXT-shaped code can talk to Nexus with minimal changes.

This first increment covers describe() and public market data — fetch_markets, fetch_ticker, fetch_tickers, fetch_order_book, fetch_ohlcv, fetch_trades, plus load_markets. Private / trading methods are a follow-up.

from nexus_exchange.ccxt_adapter import NexusExchange

with NexusExchange() as ex:
    ex.load_markets()
    ticker = ex.fetch_ticker("BTC-USDX-PERP")  # unified ticker dict
    book = ex.fetch_order_book("BTC-USDX-PERP", limit=10)  # [price, amount] levels
    candles = ex.fetch_ohlcv("BTC-USDX-PERP", "1m", limit=100)  # [ts,o,h,l,c,v]
    trades = ex.fetch_trades("BTC-USDX-PERP", limit=50)

The adapter returns plain CCXT-shaped dict/list structures and does not import or subclass ccxt — it follows CCXT's conventions without taking the dependency. See examples/ccxt_market_data.py.

API version

Currently targets Exchange API spec v0.8.1.

The pinned version lives in .api-version; the spec itself is published by nexus-xyz/nexus-exchange-api. This repo does not vendor a copy. Two CI checks keep the pin honest, answering different questions:

  • spec-drift — does the SDK still match the spec it pins? It fetches the pinned release and enforces, both ways, that every operation in endpoints.txt exists in that spec and that the operations the client code requests are exactly that list.
  • drift — is the pin still the latest release? It compares .api-version against the spec repo's newest tag.

The spec-autobump workflow opens the bump PR when a newer spec releases, labelling it breaking or non-breaking from an oasdiff classification; spec-drift runs on that PR too, so a bump that would require SDK changes cannot land quietly. The line above is bot-managed; the table below is maintained by hand when an SDK release ships a new pin.

Every request advertises the pinned tag in an X-Nexus-Api-Version header (and identifies itself with a User-Agent: nexus-exchange-py/<version>). Override the advertised tag per client with Client(api_version="vX.Y.Z") if you need to target a specific contract version.

SDK version API spec
0.1.x v0.4.0
0.2.x v0.6.2
0.3.x v0.7.1

Development

python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest          # tests — unit (mocked httpx) + an integration smoke over a
                # real loopback socket; both run offline, no network
ruff check .    # lint
mypy src        # types

tests/test_integration_smoke.py stands up a real local HTTP server and drives a real Client against it (fetch_markets / fetch_ticker / health_check), mirroring the Rust SDK's wiremock tests — so the transport, URL building, and JSON decoding are exercised end to end, not just the mock layer.

For an opt-in round-trip against a live gateway (read-only, unauthenticated; not run in CI), use the smoke script:

python scripts/smoke.py                     # testnet (default; play funds)
python scripts/smoke.py --network local
python scripts/smoke.py --base-url http://localhost:9090

License

Dual-licensed under MIT or Apache-2.0, at your option — same as the other Nexus Exchange SDKs.

Download files

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

Source Distribution

nexus_exchange-0.4.0.tar.gz (177.6 kB view details)

Uploaded Source

Built Distribution

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

nexus_exchange-0.4.0-py3-none-any.whl (80.5 kB view details)

Uploaded Python 3

File details

Details for the file nexus_exchange-0.4.0.tar.gz.

File metadata

  • Download URL: nexus_exchange-0.4.0.tar.gz
  • Upload date:
  • Size: 177.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for nexus_exchange-0.4.0.tar.gz
Algorithm Hash digest
SHA256 6bb6b753a8f575d049563cf3bb55e94b3621c4374fd5385c5cc25e1876166d6d
MD5 041fa9779dbb1066408abe91193ece71
BLAKE2b-256 ba15c1839aa9de512277ed636f6489ea5be102d32fe28051f04179d932c9d9a1

See more details on using hashes here.

Provenance

The following attestation bundles were made for nexus_exchange-0.4.0.tar.gz:

Publisher: release.yml on nexus-xyz/nexus-exchange-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file nexus_exchange-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: nexus_exchange-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 80.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for nexus_exchange-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 b9f92d5356708558220331479d7951ed7454b5649dabfd96e98c6099b2d35a21
MD5 823888f13d41b9cc8638e7521904b017
BLAKE2b-256 0ee2455db0da301bd50e98ee51785981643982a30571546ee3a60e6168df6e3e

See more details on using hashes here.

Provenance

The following attestation bundles were made for nexus_exchange-0.4.0-py3-none-any.whl:

Publisher: release.yml on nexus-xyz/nexus-exchange-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 files

Supported by

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