Metadata-Version: 2.5
Name: nexus-exchange
Version: 0.8.0
Summary: Official Python SDK for the Nexus Exchange API (beta)
Project-URL: Homepage, https://github.com/nexus-xyz/nexus-exchange-py
Project-URL: Repository, https://github.com/nexus-xyz/nexus-exchange-py
Project-URL: API spec, https://github.com/nexus-xyz/nexus-exchange-api
Author: Nexus
License-Expression: MIT OR Apache-2.0
License-File: LICENSE-APACHE
License-File: LICENSE-MIT
Keywords: crypto,exchange,nexus,perpetuals,trading
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Financial :: Investment
Requires-Python: >=3.10
Requires-Dist: eth-account>=0.11
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: mypy>=1.10; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest-httpx>=0.30; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff<0.17,>=0.16; extra == 'dev'
Requires-Dist: websockets>=12; extra == 'dev'
Provides-Extra: docs
Requires-Dist: pdoc>=15; extra == 'docs'
Provides-Extra: ws
Requires-Dist: websockets>=12; extra == 'ws'
Description-Content-Type: text/markdown

# nexus-exchange (Python)

[![License](https://img.shields.io/badge/license-MIT%2FApache--2.0-blue.svg)](#license)

Official Python SDK for the [Nexus Exchange](https://exchange.nexus.xyz) API — a
thin, typed wrapper over the public REST API.

**API reference:** <https://nexus-xyz.github.io/nexus-exchange-py/>, generated by
[pdoc](https://pdoc.dev) from the docstrings and republished on every release
(`pip install -e ".[docs,ws]" && pdoc nexus_exchange -o site` builds it locally).

> **Status: beta.** Pre-1.0: the API may change between minor versions. Built
> and tested against testnet; mainnet is not live yet.
>
> Covers **63 of the 68 operations** in the pinned spec (`.api-version`),
> listed in [`endpoints.txt`](./endpoints.txt) and checked against the
> spec in CI; see [What's supported](#whats-supported) for the five it does not
> wrap. Sync `Client` and `AsyncClient`, a WebSocket client, cursor pagination,
> and wallet and agent-key signing all ship.

## Install

```bash
pip install nexus-exchange
```

Requires Python **3.10+**. Depends only on [`httpx`](https://www.python-httpx.org/).

## Quick start

```python
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)
```

For asyncio code (a bot that streams over `WsClient` and trades in the same
event loop), `AsyncClient` is the same surface over `httpx.AsyncClient`: the same
constructor, methods, signing, retries and errors, with every method awaited and
the `iter_*` walkers returning async iterators:

```python
from nexus_exchange import AsyncClient

async with AsyncClient() as client:
    ticker = await client.fetch_ticker("BTC-USDX-PERP")
    async for fill in client.iter_my_trades(limit=500):  # needs credentials
        ...
```

Retry backoff uses `asyncio.sleep`, so it never blocks the loop. `AsyncClient`'s
methods are generated from `Client`'s (`scripts/gen_async_client.py`); a change
to `Client` is followed by a regeneration, which the tests enforce.

No credentials are needed for market data. Runnable, copy-pasteable programs for
the public and signed surface live in [`examples/`](./examples/) (config read
from the environment — no secrets in source).

## 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 |
| HMAC request signing (the plumbing for authed calls) | ✅ implemented |
| Wallet-signed auth — `login` (EIP-191), `register_agent` and `revoke_agent` (EIP-712) | ✅ implemented |
| Agent-key request signing — `x-agent` / `x-timestamp` / `x-nonce` / `x-signature` (`Client(agent=AgentSigner…)`) | ✅ implemented — trade-only; see [Agent-key request signing](#agent-key-request-signing) |
| CCXT-compatible adapter — public market data | ✅ implemented, frozen: no private methods; superseded by upstream `ccxt.nexus`, see [CCXT compatibility](#ccxt-compatibility) |
| Error taxonomy (terminal vs transient, incl. the jurisdiction `403`) | ✅ implemented |
| Typed money — `Decimal` prices/sizes (full payload still on `.raw` / `.info`) | ✅ implemented |
| Tick/lot rounding — `round_price(market, price, side)` (buy rounds down, sell up), `round_size(market, size)` (toward zero), exact `Decimal` | ✅ implemented, mirrors the Rust SDK's `markets.rs` |
| 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`, `/orders/preview`; `GET /orders`, `/orders/{id}`, `/orders/history`; `DELETE /orders`, `/orders/{id}`; `PATCH /orders/{id}` | ✅ implemented — every by-id call takes the order's `market_id` (`fetch_order(id, market_id)`, `cancel_order(id, market_id)`, `edit_order(id, market_id, …)`); the engine rejects one without it |
| Order types — `Limit`, `Market`, `StopLimit`, `StopMarket`, `TakeProfitLimit`, `TakeProfitMarket`, `TrailingStop`, `TrailingLimit` (typed `OrderRequest` builders; `trigger_price` for stop / take-profit; deprecated `stop_price` never sent) | ✅ implemented. The spec does not define which way a trigger fires for each side (see the `OrderRequest` docstring) |
| Funds — `POST /account/deposit`, `/account/credit`, `/deposits`, `/faucet`; `GET /deposits`, `/funding` | ✅ implemented |
| Bridge — `GET /bridge/assets`, `/bridge/deposits`(`/{id}`) | ✅ implemented. `POST`/`GET /bridge/deposit-addresses` are deprecated: nothing serves them. `/bridge/wallets` and `POST /bridge/wallets/challenge` are not wrapped: EX-Bridge, wrapped under ENG-5639 not here (see the migration note below) |
| Keys / agents / WS token — `GET /keys`, `DELETE /keys/{id}`, `/agents`, `POST /ws/token` | ✅ implemented. The legacy `POST /ws-tokens` is deliberately not wrapped: use `create_ws_token()` |
| Admin tiers — `GET`/`PUT`/`DELETE /admin/tiers` | ✅ implemented |
| Cursor pagination — `cursor` + `X-Next-Cursor` on all five paginated GETs | ✅ implemented |
| Create API key — `POST /keys` | ✅ implemented (session-token authenticated: pass `login().token` to `create_api_key`) |
| Venue / market info — `GET /stats`, `/stats/history`, `/status`, `/markets/{id}/risk-params`, `/markets/{id}/funding-samples` | ✅ implemented |
| WebSocket streaming — `GET /ws` (async `WsClient`, multiplexed, auto-resume) | ✅ implemented (`pip install nexus-exchange[ws]`) |
| Async REST client: every method above as a coroutine (`AsyncClient`, `httpx.AsyncClient`) | ✅ implemented |
| Retry on transient `GET` failures (5xx / 408 / `429`, honours `Retry-After`) | ✅ implemented, **off by default** — opt in with `Client(retry=RetryConfig())`; no client-side token bucket yet |
| OAuth auth | ❌ not yet |

The hand-maintained coverage source of truth is [`endpoints.txt`](./endpoints.txt).
Anything not listed there is not wrapped yet — contributions welcome.

Against the pinned spec (`.api-version`), that is **63 of 68 operations**. The
legacy `POST /ws-tokens` is deliberately not wrapped. Of the other four uncovered
operations, one is `GET /stream`, the public market-data WebSocket upgrade, and
three are the `/bridge/wallets` operations, which this SDK does not wrap, the
same as nexus-exchange-rs: EX-Bridge, wrapped under ENG-5639 not here. The
private WebSocket, `GET /ws`, is covered: `WsClient` (see below), with a token
from `create_ws_token()`.

**Migrating off the bridge wallet methods (ENG-18009).** `list_bridge_wallets`,
`create_bridge_wallet_challenge` and `register_bridge_wallet` (with the
`BridgeWallet`, `BridgeWalletsResponse` and `BridgeWalletChallenge` types) were
removed outright, not deprecated. Their routes are not in the spec on the
monorepo's main, the server deleted them (ENG-13801), and they are EX-Bridge
scope (ENG-5639), which nexus-exchange-rs does not wrap either. There is no
replacement: nothing in the spec takes their place, so delete the calls rather
than looking for a new method.

### Method names

Each method is `snake_case` of its operation's spec `operationId`
(`fetchTradingFees` → `fetch_trading_fees`), and paging helpers keep that stem
(`fetch_orders_page`, `iter_orders`). `scripts/check_spec_drift.py` enforces it.
The methods below were renamed to follow that rule (ENG-17744). The old names
still work for one minor release, and raise `DeprecationWarning`. One exception:
`mint_web_socket_token` (briefly `create_ws_token_legacy`) wrapped the legacy
`POST /ws-tokens` and was removed outright (ENG-18010); call `create_ws_token()`
(`POST /ws/token`) instead.

| Old | New |
|---|---|
| `fetch_market_summaries` | `fetch_markets_summary` |
| `fetch_market_adl_events` | `fetch_adl_events` |
| `fetch_account_adl_history` | `fetch_adl_history` |
| `fetch_service_health` | `fetch_status` |
| `sign_in` | `login` |
| `fetch_closed_positions`, `fetch_closed_positions_page`, `iter_closed_positions` | `fetch_positions_history`, `fetch_positions_history_page`, `iter_positions_history` |
| `fetch_account_fees` | `fetch_trading_fees` |
| `fetch_account_funding` | `fetch_funding_history` |
| `adjust_margin` | `add_margin` |
| `fetch_order_history`, `fetch_order_history_page`, `iter_order_history` | `fetch_orders`, `fetch_orders_page`, `iter_orders` |
| `amend_order` | `edit_order` |
| `set_account_tier` | `set_tier` |
| `fetch_tier_overrides` | `fetch_tiers` |
| `reset_account_tier` | `delete_tier` |
| `list_bridge_deposit_addresses` | `fetch_bridge_deposit_addresses` (both deprecated: the route is unserved, see [Bridge](#bridge)) |

### 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:

```python
from nexus_exchange import Client, Funds, Network

Network.TESTNET.base_url  # 'https://api.testnet.nexus.xyz/v1'
Network.TESTNET.ws_market_data_url  # 'wss://api.testnet.nexus.xyz/v1/stream'
Network.TESTNET.ws_authenticated_url  # 'wss://api.testnet.nexus.xyz/v1/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:

```python
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 **durable**
per-network values, recorded here so they live in one place. Testnet's are live;
mainnet's host has no DNS record at all, so treat that one as a published target
rather than something to connect to. Neither WS base is dialled on your behalf:
the streaming client (`WsClient`) connects only to the URL you pass it. What the
REST client actually sends to is `base_url`.

Testnet's `base_url` is the spec's published REST base, which ends in `/v1`
(EDR-006). Every method appends the spec's bare path (`/orders`) and signs that
path; the edge strips `/v1` before the indexer verifies. Copy the base whole: the
bare host routes nothing and answers `404`.

Three things worth knowing before you pick one:

- **Mainnet has no default base URL yet.** Its host (`api.nexus.xyz`) is
  decided but has no DNS record, 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` and
  `revoke_agent` refuse to sign without one rather than defaulting: a wrong
  domain either fails verification or produces a signature valid on a
  *different* network.
- **`register_agent` and `revoke_agent` need the network.** The server salts the
  agent-management domain with `keccak256(network name)`, so pass `network=`
  (the salt is in `signing_domain.salt`). A custom target has no salt and is
  refused.

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

```python
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",
    )
)
```

#### 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:

```python
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",
    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.

The base URL is 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/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 (`/v1` is one): it is sent
but not signed, so it must be a prefix the deployment strips before it verifies.
The same checks apply to a raw `base_url` override, including the one mainnet
requires.

**A bare `base_url` with no network named is deprecated** ([#61][pr61]) — 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:

```python
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.
A URL passed alongside a named network stays, and keeps that network's semantics
because the caller has declared them:

```python
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: one base, bare paths

Every request goes to `base_url` plus the spec's bare path and is signed over
that path (EDR-006, ENG-18322). On testnet that sends `/v1/orders` and signs
`/orders`. The five bridge reads are the one exception: the pinned spec
(`.api-version`) has no bare twins for them yet, so they keep the `/api/v1`
spelling (`/v1/api/v1/bridge/assets`, signed as `/api/v1/bridge/assets`) until a
published release declares the bare paths.

The other SDKs use the same base: `https://api.testnet.nexus.xyz/v1` in Rust
(`Network::Testnet.base_url()`) and TypeScript. Don't paste a base that already
carries `/api/v1`: it composes `/api/v1/orders` under it, which the server does
not route.

[pr61]: https://github.com/nexus-xyz/nexus-exchange-py/pull/61

## Authentication

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

```text
<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 wallet-authorized
flows are different: an EVM wallet key authorizes a **session**, an **agent
key**, or an agent key's **revocation**. Login and registration carry the
signature in the request *body* (those POSTs are themselves unauthenticated);
revocation carries it in four `x-wallet-*` headers, the only credential
`DELETE /agents/{address}` accepts. This mirrors the
[Rust SDK](https://github.com/nexus-xyz/nexus-exchange-rs)'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`](https://pypi.org/project/eth-account/) dependency, which ships
with the SDK.

```python
import time

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.login(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=20056,
        network=client.network,  # salts the domain: valid on this network only
        label="my-bot",
    )
    registered = client.register_agent(registration)
    print(registered.agent_address, registered.expires_at)

    # EIP-712 → DELETE /agents/{address}. The nonce is the Unix time in ms when
    # you sign: it must be within [now - 5 min, now + 60 s] and strictly greater
    # than this wallet's last rename/revoke nonce, so sign a fresh one per call.
    revocation = signer.revoke_agent(
        agent="0x<agent-address>",
        nonce=int(time.time() * 1000),
        chain_id=20056,
        network=client.network,
    )
    client.revoke_agent(revocation)  # sends no HMAC: any client can revoke
```

### Agent-key request signing

Once an agent key is registered, it signs each **request** itself, so the wallet
key never has to be online. Install it on a client with `agent=`; every signed
call then sends the four agent headers instead of HMAC:

```python
from nexus_exchange import AgentSigner, Client, Network

agent = AgentSigner.from_hex("0x<agent-private-key>")  # register agent.address first

with Client(Network.TESTNET, agent=agent) as client:
    client.fetch_account_state()
```

The scheme (spec `agentAuth`): the canonical string is

```text
{METHOD}\n{path}\n{query}\n{sha256hex(body)}\n{timestamp_ms}\n{nonce}
```

(method **first** — not the HMAC order), hashed with raw `keccak256` and **no
EIP-191 prefix**, signed low-S as `0x` + 65-byte `r||s||v` with `v ∈ {27, 28}`.
`x-timestamp` must be within ±30 s of the server's clock. The signer's output is
pinned byte-for-byte to the spec's `x-nexus-test-vectors`.

- **One request credential.** `agent=` together with `api_key` *and*
  `api_secret` authenticates with HMAC; the agent then only signs trading actions
  (below). Half an HMAC key beside an agent raises `ValueError`. A session token
  passed to `create_api_key` is unaffected — that call sends only the bearer.
- **Nonces.** The signer issues `max(last + 1, timestamp_ms)` under a lock, so
  nonces are unique and increasing per signer, across threads and restarts.
  Writes must carry a strictly increasing nonce; reads don't consume one. Every
  retry attempt (`retry=RetryConfig()`) is re-signed with a fresh timestamp and
  nonce.
- **Keep one write in flight per agent key.** Nonces increase when *issued*, not
  when they *arrive*: two concurrent writes from one agent can reach the server
  out of order, and the lower nonce is then refused as a replay — with the same
  opaque `401` as a bad signature. The SDK does not queue writes for you (the fix
  is being decided in ENG-17010). Serialize writes per agent, or register one
  agent key per concurrent writer (and per process).
- **Agent keys cannot withdraw.** They are trade-only. An agent client refuses
  any withdrawal route (`/withdrawals`, `/account/withdraw`,
  `/bridge/withdrawals`) and `fetch_agents` **locally**, with
  `AgentKeyRefusedError`, before anything is signed or sent — the server would
  `403` them. Use an HMAC client for those; `create_ws_token` accepts agent
  keys. `revoke_agent` works from any client: the wallet's signature is its
  credential.

#### Signed trading actions

The eight order-path writes (`POST /orders`, `POST /orders/batch`,
`PATCH /orders/{id}`, `DELETE /orders/{id}`, `DELETE /orders`,
`POST /account/margin`, `POST /account/margin-mode`, `POST /leverage`) can carry
an EIP-712 action signed by an agent key (spec "Signed trading actions"). Once
the engine enforces it, an order without one is refused, **HMAC orders
included**. On a network whose `deployment_domain` is set, the agent signs the
route's struct into `x-action-signature` / `x-action-timestamp` /
`x-action-nonce`: an agent-only client sends them with `x-agent` in place of the
canonical string, and an HMAC client given `agent=` sends them beside its HMAC
headers. The struct names the account, so the agent needs `account=`:

```python
agent = AgentSigner.from_hex("0x<agent-private-key>", account="0x<wallet>")
dev = NetworkConfig.custom(
    label="dev", funds=Funds.PLAY, base_url="https://<apps-dev>", deployment_domain="devnet"
)
with Client(dev, agent=agent) as client:  # add acting_account="0x<sub>" for a subaccount
    client.create_order(order)
```

No named network sets `deployment_domain` yet (the server refuses a typed action
it has no domain for), so on those every request keeps the canonical string.

## Bridge

Track cross-chain deposits via the `/bridge` surface (USDC/USDX in Phase A).
These reads track a deposit; they do not start one. List the supported chains,
then poll a deposit until `status` is `credited`:

`fetch_bridge_assets` is public — the chain and asset catalogue is the same for
everyone, so it needs no key. Everything after it is signed.

```python
assets = client.fetch_bridge_assets()

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

The SDK has no way to get a deposit address today.
`create_bridge_deposit_address` and `fetch_bridge_deposit_addresses` (old name
`list_bridge_deposit_addresses`) are deprecated: no server implements `/bridge/deposit-addresses` (its design was
cancelled), and no replacement has shipped yet. To fund a testnet account, use
`claim_faucet()` or `claim_credit()`.

See [`examples/bridge_deposit.py`](./examples/bridge_deposit.py).

## Portfolio

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

```python
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_trading_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:

```python
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.

## Conditional orders

Stop, take-profit and trailing orders have typed builders on `OrderRequest`.
`trigger_price` is sent as a decimal string. The deprecated `stop_price` field
is never sent.

```python
from decimal import Decimal
from nexus_exchange import OrderRequest

m, qty = "BTC-USDX-PERP", Decimal("0.01")
OrderRequest.stop_market(m, "Sell", Decimal("58000"), qty, reduce_only=True)
OrderRequest.stop_limit(
    m, "Sell", Decimal("58000"), Decimal("57900"), qty
)  # trigger, then limit price
OrderRequest.take_profit_market(m, "Sell", Decimal("72000"), qty, reduce_only=True)
OrderRequest.take_profit_limit(m, "Sell", Decimal("72000"), Decimal("72000"), qty)
OrderRequest.trailing_stop(m, "Sell", qty, 150)  # fires a market order after a 150 bp retrace
```

The spec's per-type requirements are checked when the request is built, and a
violation raises `ValueError` before anything is sent:

- stop and take-profit types need `trigger_price`, and every other type rejects it;
- the limit family (`Limit`, `StopLimit`, `TakeProfitLimit`) needs `price`;
- `TrailingStop` needs `trailing_offset_bps`.

**The trigger direction for each side is not specified.** The spec says stops
fire on the "adverse" crossing and take-profits on the "favorable" one. It
never says which way that is for a `Buy` or a `Sell`. The SDK sends
`trigger_price` as given and does not check it against the side or the mark.
See [`examples/conditional_orders.py`](./examples/conditional_orders.py).

## 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_orders` / `iter_orders` / `fetch_orders_page` | 500 |
| `GET /positions/closed` | `fetch_positions_history` / `iter_positions_history` / `fetch_positions_history_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:

```python
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):

```python
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.

## WebSocket streaming

`WsClient` (`pip install nexus-exchange[ws]`) multiplexes subscriptions on one
socket and reconnects on its own, resuming each subscription from the last
`seq` it saw so the server replays what was missed.

```python
async with WsClient("wss://api.testnet.nexus.xyz/v1") as ws:
    sub = ws.subscribe("trades", market="BTC-USDX-PERP")
    async for event in sub:
        if event.out_of_sync:
            while sub.health == "resyncing":
                await asyncio.sleep(0.1)
            if sub.health == "closed":
                break
            ...  # now refetch the state you track over REST
            continue
        handle(event.data)
```

When the server can no longer replay the gap, it ends the subscription and
the stream yields an `out_of_sync` event. The client subscribes again by
itself; `sub.health` reads `resyncing` until the server acknowledges, then
`live`, or `closed` for good if the subscription is torn down meanwhile, which
is why the loop waits on `resyncing` rather than for `live`. Refetch over REST only once `health` is `live` again: a refetch that
lands before the new subscription attaches can miss events published in
between. Never resubscribe yourself. See `examples/ws_trades.py`.

## CCXT compatibility

[CCXT](https://github.com/ccxt/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.

**The adapter is frozen at public market data.** It will be superseded by the
upstream `nexus` exchange class in [ccxt/ccxt](https://github.com/ccxt/ccxt)
(`ccxt.nexus()`), and removed from this package after that class lands. No
private endpoints will be added here.

- **Works:** `describe()`, `load_markets`, `fetch_markets`, `fetch_ticker`,
  `fetch_tickers`, `fetch_order_book`, `fetch_ohlcv`, `fetch_trades`.
- **Absent:** every private / trading method. There is no `fetch_balance`,
  `fetch_positions`, `create_order`, `cancel_order`, `fetch_orders`,
  `fetch_open_orders` or `fetch_my_trades`; `describe()["has"]` reports them
  `False`.

For balances, positions and trading, use the native `nexus_exchange.Client` in
this package (see [What's supported](#whats-supported)).

```python
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

<!-- api-version-sync:start -->

Currently targets Exchange API spec **`v0.8.1`**.

<!-- api-version-sync:end -->

The pinned version lives in [`.api-version`](./.api-version); the spec itself is
published by
[`nexus-xyz/nexus-exchange-api`](https://github.com/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`](./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](https://github.com/oasdiff/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` |

## Benchmarks

`python bench/signing_bench.py` measures client-side request signing, both HMAC
and agent-key (`AgentSigner`), on one fixed order request (no network I/O), and
prints p50 / p95 per signature, signatures/sec, the Python version and the
`eth-keys` backend it used. Agent-key signing is about 35x faster with
`pip install coincurve`, which `eth-keys` picks up automatically. Published
results and methodology: the API Reference's Rate Limits guide, "Can your
signer keep up?".

## Development

```bash
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
python scripts/gen_async_client.py  # after changing `Client`: regenerate AsyncClient
```

`tests/test_integration_smoke.py` stands up a real local HTTP server and drives
a real `Client` against it (`fetch_markets` / `fetch_ticker` / `fetch_order_book`),
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:

```bash
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](./LICENSE-MIT) or [Apache-2.0](./LICENSE-APACHE), at
your option — same as the other Nexus Exchange SDKs.
